Compare commits
134
Commits
ca51594195
...
v0.1.20
@@ -17,3 +17,17 @@ When producing many files (wiki pages, content batches, bulk docs):
|
|||||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||||
|
|
||||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||||
|
|
||||||
|
## Team monitoring (stuck agent detection)
|
||||||
|
|
||||||
|
When leading a team (sprint, workshop, or any multi-agent session):
|
||||||
|
|
||||||
|
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||||
|
> If you have been working on a single task for more than 15 minutes
|
||||||
|
> without making progress, message the team lead with what is blocking
|
||||||
|
> you. Do not keep retrying the same approach silently.
|
||||||
|
|
||||||
|
**Team lead proactive checks:**
|
||||||
|
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
|
||||||
|
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
|
||||||
|
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
|
||||||
|
|||||||
@@ -46,6 +46,14 @@
|
|||||||
"Bash(tea *)",
|
"Bash(tea *)",
|
||||||
"Bash(tooling/tea-comment *)",
|
"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(chmod *)",
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
"Bash(find *)",
|
"Bash(find *)",
|
||||||
|
|||||||
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
|
|||||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > /tmp/pr-body.md << 'EOF'
|
|
||||||
## Summary
|
|
||||||
...
|
|
||||||
EOF
|
|
||||||
tea pr create \
|
tea pr create \
|
||||||
--repo jpmschweitzer/settled-reach \
|
--repo jpmschweitzer/settled-reach \
|
||||||
--login schweitz \
|
--login schweitz \
|
||||||
--title "<title>" \
|
--title "<title>" \
|
||||||
--description "$(cat /tmp/pr-body.md)" \
|
--description "## Summary ..." \
|
||||||
--base main \
|
--base main \
|
||||||
--head <branch>
|
--head <branch>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,11 +16,22 @@ on the branch type. All reviewers must approve for a clean review.
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### 1. Determine the branch
|
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
|
||||||
|
|
||||||
If the user provided a branch name as argument, use it. Otherwise use the
|
```bash
|
||||||
current branch (`git branch --show-current`). If on `main`, ask the user
|
git branch --show-current
|
||||||
which branch to review.
|
```
|
||||||
|
|
||||||
|
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||||
|
"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
|
||||||
|
|
||||||
|
If the user provided a branch name as argument, use it. Otherwise list open
|
||||||
|
PRs and ask the user which branch to review.
|
||||||
|
|
||||||
To list open PRs on Gitea:
|
To list open PRs on Gitea:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
|
|||||||
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
|
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
|
||||||
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
|
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
|
||||||
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
|
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
|
||||||
|
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
|
||||||
|
|
||||||
When writing briefings, name the assigned agents in the **Agents** line of each
|
When writing briefings, name the assigned agents in the **Agents** line of each
|
||||||
file so the team knows who to spawn.
|
file so the team knows who to spawn.
|
||||||
|
|
||||||
|
### Planning Team Tickets
|
||||||
|
|
||||||
|
Some tickets need **design discussion** before implementation can begin — tagged
|
||||||
|
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
|
||||||
|
questions. These run on the `planning` branch as structured discussions with
|
||||||
|
the user and a purpose-assembled agent panel.
|
||||||
|
|
||||||
|
**When to create a planning ticket:**
|
||||||
|
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
|
||||||
|
- Ticket blocks 2+ downstream tickets across different teams
|
||||||
|
- Open Q-NNN items that block sprint candidates
|
||||||
|
- Architectural decisions that need multi-domain input before implementation
|
||||||
|
|
||||||
|
**Planning briefing format** (differs from implementation briefings):
|
||||||
|
- **Agents line**: List agents by domain relevance, not fixed team roster.
|
||||||
|
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
|
||||||
|
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
|
||||||
|
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
|
||||||
|
records decisions, updates domain files) and SI (project manager — creates
|
||||||
|
follow-up tickets, updates sprint assignments).
|
||||||
|
- **Discussion rounds**: Structure the conversation into 2-3 rounds
|
||||||
|
(inventory → proposals → convergence)
|
||||||
|
- **Context section**: List all existing design docs, decisions, and related
|
||||||
|
tickets that participants must read before the discussion
|
||||||
|
- **Output specification**: What the discussion must produce — typically a
|
||||||
|
D-record in `decisions/`, possibly a design doc in `docs/design/`
|
||||||
|
- **Decision questions**: Specific questions the discussion must answer,
|
||||||
|
not open-ended exploration
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### 1. Run sprint prepare
|
### 1. Run sprint prepare
|
||||||
|
|||||||
@@ -288,24 +288,63 @@ Task(
|
|||||||
prompt: "You are on the {team} team for Sprint {N}.
|
prompt: "You are on the {team} team for Sprint {N}.
|
||||||
Branch: `{team}`
|
Branch: `{team}`
|
||||||
|
|
||||||
RULES:
|
RULES (NON-NEGOTIABLE):
|
||||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
|
||||||
checkout, branch, stash, tag, etc.). All git operations are
|
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||||
handled by the team lead.
|
checkout, branch, stash, tag, etc.). All git operations are
|
||||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
handled by the team lead. No exceptions.
|
||||||
the exact command with no wrappers or chaining. Examples:
|
|
||||||
db/connectors/ticket show 528
|
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||||
db/connectors/ticket list --sprint {N}
|
the exact command with no wrappers or chaining. Examples:
|
||||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
db/connectors/ticket show 528
|
||||||
add cleanup commands. Just the bare command.
|
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
|
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||||
2. Read the decision files referenced in the briefing.
|
2. Read the decision files referenced in the briefing.
|
||||||
3. Check TaskList for available work.
|
3. Check TaskList for available work.
|
||||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||||
mark it in_progress, and implement it.
|
mark it in_progress, and implement it.
|
||||||
5. When done, mark the task completed and check TaskList for
|
5. Before marking done, verify:
|
||||||
the next available task.
|
- 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.",
|
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||||
description: "Sprint {N} {team}: {name}",
|
description: "Sprint {N} {team}: {name}",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ run_check() {
|
|||||||
|
|
||||||
# --- Checks ---
|
# --- Checks ---
|
||||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||||
|
run_check "tooling/check-decision-ids" "decision ID duplication"
|
||||||
|
|
||||||
if [ "$ERRORS" -gt 0 ]; then
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ Thumbs.db
|
|||||||
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
||||||
.claude/plans/
|
.claude/plans/
|
||||||
.claude/projects/
|
.claude/projects/
|
||||||
|
.claude/agent-memory/
|
||||||
|
|||||||
+117
-8
@@ -6,9 +6,115 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v0.1.20] — 2026-02-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
|
||||||
|
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
|
||||||
|
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
|
||||||
|
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
|
||||||
|
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
|
||||||
|
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
|
||||||
|
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
|
||||||
|
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
|
||||||
|
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
|
||||||
|
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
|
||||||
|
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
|
||||||
|
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
|
||||||
|
- Validate dangling with_role references in TriangleDef constraint validation
|
||||||
|
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
|
||||||
|
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
|
||||||
|
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
|
||||||
|
- Consolidate near-identical unit tests with integration counterparts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
|
||||||
|
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
|
||||||
|
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
|
||||||
|
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
|
||||||
|
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
|
||||||
|
|
||||||
|
## [v0.1.19] — 2026-02-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
|
||||||
|
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
|
||||||
|
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
|
||||||
|
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
|
||||||
|
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
|
||||||
|
- 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)
|
- `.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)
|
||||||
|
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
|
||||||
|
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
|
||||||
|
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
|
||||||
|
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
|
||||||
|
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
|
||||||
|
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
|
||||||
|
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
|
||||||
|
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
|
||||||
|
- 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
|
- `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)
|
||||||
|
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
|
||||||
|
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
|
||||||
|
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
|
||||||
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||||
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||||
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||||
@@ -17,6 +123,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
- 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)
|
- 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
|
### Changed
|
||||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
- 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
|
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||||
@@ -35,6 +146,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||||
- DialogueResponse cross-language fixture for GDScript testing
|
- DialogueResponse cross-language fixture for GDScript testing
|
||||||
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||||
|
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||||
|
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||||
|
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||||
|
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||||
|
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||||
|
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||||
@@ -49,14 +166,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
- assert!(false) → panic!() in serialization tests (clippy)
|
- assert!(false) → panic!() in serialization tests (clippy)
|
||||||
- SetFacing and TeleportToHub added to roundtrip test coverage
|
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
|
||||||
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
|
||||||
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
|
||||||
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
|
||||||
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
|
||||||
- pr-push and pr-review skills updated with team lifecycle awareness
|
|
||||||
|
|
||||||
## [v0.1.15] — 2026-02-23
|
## [v0.1.15] — 2026-02-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ This project uses **git worktrees** in a shared parent directory (`settled-reach
|
|||||||
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||||
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||||
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||||
|
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
@@ -55,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 |
|
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
| 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 search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||||
|
|
||||||
### File conventions
|
### File conventions
|
||||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
- 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.
|
- 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
|
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||||
- Briefings: one per agent, updated after decision-producing rounds
|
- 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 \
|
pre-pr-server pre-pr-client pre-pr-content \
|
||||||
fixtures-client golden-diff golden-update \
|
fixtures-client golden-diff golden-update \
|
||||||
checklist-validate checklist-generate \
|
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 ---
|
# --- Configuration ---
|
||||||
|
|
||||||
@@ -23,11 +24,15 @@ help:
|
|||||||
@echo " make stop Stop any running server instance"
|
@echo " make stop Stop any running server instance"
|
||||||
@echo " make client Run the Godot client (test mode)"
|
@echo " make client Run the Godot client (test mode)"
|
||||||
@echo " make server Run the Rust simulation server"
|
@echo " make server Run the Rust simulation server"
|
||||||
@echo " make test Run all tests"
|
@echo " make test Run all tests"
|
||||||
@echo " make lint Run all linters"
|
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
|
||||||
@echo " make ci Run full CI pipeline locally"
|
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
|
||||||
@echo " make ci-client Run client CI checks"
|
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
|
||||||
@echo " make ci-server Run server CI checks"
|
@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 check-protocol Verify server/client protocol versions match"
|
||||||
@echo " make clean Remove build artifacts and caches"
|
@echo " make clean Remove build artifacts and caches"
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -128,7 +133,7 @@ stop:
|
|||||||
test: test-server test-client
|
test: test-server test-client
|
||||||
|
|
||||||
test-server:
|
test-server:
|
||||||
cd server && cargo nextest run
|
tests/run-rust
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
cd server && cargo test --test gen_fixtures -- --ignored
|
cd server && cargo test --test gen_fixtures -- --ignored
|
||||||
@@ -169,8 +174,19 @@ golden-update:
|
|||||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||||
|
|
||||||
test-client:
|
test-client:
|
||||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
tests/run-godot
|
||||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
|
||||||
|
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 ---
|
# --- Lint ---
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ notifications:
|
|||||||
|
|
||||||
# System
|
# System
|
||||||
save_complete: "Progress saved."
|
save_complete: "Progress saved."
|
||||||
|
load_complete: "Session restored."
|
||||||
|
save_failed: "Save failed."
|
||||||
|
load_failed: "Load failed."
|
||||||
connection_lost: "Signal interrupted."
|
connection_lost: "Signal interrupted."
|
||||||
connection_restored: "Signal restored."
|
connection_restored: "Signal restored."
|
||||||
|
|
||||||
@@ -125,6 +128,17 @@ knowledge_panel:
|
|||||||
confidence_medium: "Likely"
|
confidence_medium: "Likely"
|
||||||
confidence_low: "Unconfirmed"
|
confidence_low: "Unconfirmed"
|
||||||
confidence_rumor: "Hearsay"
|
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)
|
# TUTORIAL TEXT (DIEGETIC)
|
||||||
@@ -159,6 +173,8 @@ dialogue:
|
|||||||
menu:
|
menu:
|
||||||
pause_title: "Paused"
|
pause_title: "Paused"
|
||||||
resume: "Resume"
|
resume: "Resume"
|
||||||
|
new_game: "New Game"
|
||||||
|
continue: "Continue"
|
||||||
settings: "Settings"
|
settings: "Settings"
|
||||||
save_game: "Save"
|
save_game: "Save"
|
||||||
load_game: "Load"
|
load_game: "Load"
|
||||||
|
|||||||
+17
-1
@@ -11,7 +11,7 @@ config_version=5
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="The Settled Reach"
|
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/features=PackedStringArray("4.6", "GL Compatibility")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
|||||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||||
|
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||||
|
|
||||||
[audio]
|
[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)
|
"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={
|
teleport_hub={
|
||||||
"deadzone": 0.5,
|
"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)
|
"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]
|
[rendering]
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=23 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/main.gd" id="1_main"]
|
||||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
[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="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"]
|
[node name="Game" type="Node2D"]
|
||||||
script = ExtResource("1_main")
|
script = ExtResource("1_main")
|
||||||
@@ -118,6 +121,9 @@ zoom = Vector2(2, 2)
|
|||||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||||
layer = 10
|
layer = 10
|
||||||
|
|
||||||
|
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||||
|
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||||
|
|
||||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||||
|
|
||||||
@@ -130,6 +136,15 @@ layer = 10
|
|||||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
[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) ---
|
; --- UI layer (CanvasLayer 20) ---
|
||||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||||
@@ -137,8 +152,6 @@ layer = 20
|
|||||||
|
|
||||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
[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")]
|
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||||
|
|
||||||
; D-053: Stance indicator — top-right, color-coded
|
; 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
|
extends Node
|
||||||
|
|
||||||
|
signal game_id_changed(new_id: String)
|
||||||
|
|
||||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||||
# Tiles use format: [{x, y, z, type}].
|
# Tiles use format: [{x, y, z, type}].
|
||||||
var current_snapshot: Dictionary = {}
|
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 current_tick: int = 0
|
||||||
var player_position: Vector2 = Vector2.ZERO
|
var player_position: Vector2 = Vector2.ZERO
|
||||||
var visible_entities: Array = []
|
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).
|
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||||
var rng_seed: Variant = null
|
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)
|
# v7 fields (#431, D-059/D-060)
|
||||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
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_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
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.
|
# #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.
|
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||||
var medium_sound_events: Array = []
|
var medium_sound_events: Array = []
|
||||||
@@ -79,14 +109,17 @@ var medium_sound_events: Array = []
|
|||||||
var close_sound_events: Array = []
|
var close_sound_events: Array = []
|
||||||
|
|
||||||
# D-071 (#530): Consecutive ticks without player position change.
|
# D-071 (#530): Consecutive ticks without player position change.
|
||||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
|
||||||
|
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
||||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||||
var stationary_ticks: int = 0
|
var stationary_ticks: int = 0
|
||||||
|
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
|
||||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||||
|
|
||||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
# D-020: Read directly from snapshot "zone_id" field.
|
||||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
|
||||||
|
# Empty string when zone_id field absent.
|
||||||
var current_zone_id: String = ""
|
var current_zone_id: String = ""
|
||||||
|
|
||||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||||
@@ -110,12 +143,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
push_warning("GameState: no Player entity found in %d entities" % [
|
push_warning("GameState: no Player entity found in %d entities" % [
|
||||||
visible_entities.size()])
|
visible_entities.size()])
|
||||||
|
|
||||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||||
# Compares current player_position against previous snapshot's position.
|
# Prefer server-sent value; fall back to client-side accumulation until server populates.
|
||||||
if player_position == _prev_player_position:
|
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||||
stationary_ticks += 1
|
# D-020: direct field assignment from server-authoritative snapshot.
|
||||||
|
stationary_ticks = snapshot.stationary_ticks
|
||||||
else:
|
else:
|
||||||
stationary_ticks = 0
|
# DEPRECATED fallback — client-side accumulation. Remove when server sends
|
||||||
|
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
|
||||||
|
# driving state on the client). Server tracks this in ListeningFocus component.
|
||||||
|
if player_position == _prev_player_position:
|
||||||
|
stationary_ticks += 1
|
||||||
|
else:
|
||||||
|
stationary_ticks = 0
|
||||||
_prev_player_position = player_position
|
_prev_player_position = player_position
|
||||||
|
|
||||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||||
@@ -240,16 +280,49 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
medium_sound_events = []
|
medium_sound_events = []
|
||||||
close_sound_events = []
|
close_sound_events = []
|
||||||
|
|
||||||
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
|
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||||
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
|
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||||
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
|
# Only update if the field is present — absence means "no change since last tick".
|
||||||
var _tile_by_coord: Dictionary = {}
|
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||||
for vtile in visible_tiles:
|
discovered_pois = snapshot.discovered_pois
|
||||||
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||||
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
discovered_pois = snapshot.poi_list
|
||||||
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
|
||||||
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
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-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||||
|
# Prefer server-sent top-level value; fall back to client-side tile lookup until
|
||||||
|
# server populates top-level "zone_id" in ObserverSnapshot.
|
||||||
|
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||||
|
# D-020: direct field assignment from server-authoritative snapshot.
|
||||||
|
current_zone_id = snapshot.zone_id
|
||||||
|
else:
|
||||||
|
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
|
||||||
|
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
|
||||||
|
# identity on the client via tile iteration). Server sends zone_id per
|
||||||
|
# VisibleTile but not as a top-level snapshot field.
|
||||||
|
var _tile_by_coord: Dictionary = {}
|
||||||
|
for vtile in visible_tiles:
|
||||||
|
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||||
|
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||||
|
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
||||||
|
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||||
|
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||||
|
|
||||||
# v2: visible_tiles with visibility sectors
|
# v2: visible_tiles with visibility sectors
|
||||||
# Derives visible_positions when not explicitly provided (real server mode)
|
# Derives visible_positions when not explicitly provided (real server mode)
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ enum Action {
|
|||||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
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)
|
SET_FACING, # D-054: facing octant update (no movement)
|
||||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
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] = []
|
var input_queue: Array[Dictionary] = []
|
||||||
@@ -106,15 +109,29 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
action = Action.TOGGLE_STANCE_DOWN
|
action = Action.TOGGLE_STANCE_DOWN
|
||||||
elif event.is_action_pressed("bug_report"):
|
elif event.is_action_pressed("bug_report"):
|
||||||
action = Action.BUG_REPORT
|
action = Action.BUG_REPORT
|
||||||
|
elif event.is_action_pressed("open_journal"):
|
||||||
|
action = Action.OPEN_JOURNAL
|
||||||
elif event.is_action_pressed("teleport_hub"):
|
elif event.is_action_pressed("teleport_hub"):
|
||||||
if GameState.gauntlet_mode:
|
if GameState.gauntlet_mode:
|
||||||
action = Action.TELEPORT_HUB
|
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:
|
if action != -1:
|
||||||
input_queue.append({
|
var entry := {
|
||||||
"action": action,
|
"action": action,
|
||||||
"timestamp_msec": Time.get_ticks_msec(),
|
"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()
|
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
|
extends Node
|
||||||
|
|
||||||
# Connection states
|
# Connection states
|
||||||
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||||
|
|
||||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||||
var _test_tick: int = 0
|
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
|
||||||
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 _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
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 _connect_retries: int = 0
|
||||||
var _retry_timer: float = 0.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
|
# Signals
|
||||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||||
signal snapshot_received(snapshot: Dictionary)
|
signal snapshot_received(snapshot: Dictionary)
|
||||||
|
signal handshake_complete(protocol_version: int)
|
||||||
|
signal handshake_failed(reason: String)
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if test_mode:
|
if test_mode:
|
||||||
|
harness = TestHarness.new()
|
||||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
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:
|
func reset_test_state() -> void:
|
||||||
_test_tick = 0
|
if harness: harness.reset()
|
||||||
_test_player_pos = Vector2i(10, 10)
|
|
||||||
_test_facing = "North"
|
func _test_snapshot() -> Dictionary:
|
||||||
_test_input_queue.clear()
|
return harness.snapshot()
|
||||||
_test_in_dialogue = false
|
|
||||||
_test_gauntlet_mode = false
|
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||||
_test_npc_relationship = "Unknown"
|
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
|
# Change connection state and emit signal
|
||||||
func _set_state(new_state: ConnectionState) -> void:
|
func _set_state(new_state: ConnectionState) -> void:
|
||||||
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
|
|||||||
# Spawn server subprocess
|
# Spawn server subprocess
|
||||||
if not server_path.is_empty():
|
if not server_path.is_empty():
|
||||||
_server = ServerProcess.new()
|
_server = ServerProcess.new()
|
||||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
# 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)])
|
# 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:
|
if pid <= 0:
|
||||||
push_error("SimBridge: failed to start server")
|
push_error("SimBridge: failed to start server")
|
||||||
_set_state(ConnectionState.ERROR)
|
_set_state(ConnectionState.ERROR)
|
||||||
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
|
|||||||
_bridge.poll()
|
_bridge.poll()
|
||||||
match _bridge.get_status():
|
match _bridge.get_status():
|
||||||
StreamPeerTCP.STATUS_CONNECTED:
|
StreamPeerTCP.STATUS_CONNECTED:
|
||||||
_set_state(ConnectionState.CONNECTED)
|
_handshake_start_usec = Time.get_ticks_usec()
|
||||||
|
_set_state(ConnectionState.HANDSHAKING)
|
||||||
StreamPeerTCP.STATUS_CONNECTING:
|
StreamPeerTCP.STATUS_CONNECTING:
|
||||||
pass # Still connecting, wait
|
pass # Still connecting, wait
|
||||||
StreamPeerTCP.STATUS_ERROR:
|
StreamPeerTCP.STATUS_ERROR:
|
||||||
@@ -134,6 +179,62 @@ func _process(delta: float) -> void:
|
|||||||
_bridge = null # Reset and retry
|
_bridge = null # Reset and retry
|
||||||
return
|
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:
|
if _bridge == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -170,9 +271,13 @@ func _process(delta: float) -> void:
|
|||||||
push_warning("SimBridge: connection lost")
|
push_warning("SimBridge: connection lost")
|
||||||
_set_state(ConnectionState.DISCONNECTED)
|
_set_state(ConnectionState.DISCONNECTED)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Input / snapshot ----------------------------------------------------------
|
||||||
|
|
||||||
# Send input to simulation server.
|
# Send input to simulation server.
|
||||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
# 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.
|
# Returns OK on success, or an error code on failure.
|
||||||
func send_input(player_input: Dictionary) -> Error:
|
func send_input(player_input: Dictionary) -> Error:
|
||||||
if state != ConnectionState.CONNECTED:
|
if state != ConnectionState.CONNECTED:
|
||||||
@@ -182,25 +287,20 @@ func send_input(player_input: Dictionary) -> Error:
|
|||||||
var wire_name: String = action_enum_to_wire(action)
|
var wire_name: String = action_enum_to_wire(action)
|
||||||
if not wire_name.is_empty():
|
if not wire_name.is_empty():
|
||||||
if wire_name == "SetFacing":
|
if wire_name == "SetFacing":
|
||||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
|
||||||
var facing: String = ""
|
var facing: String = ""
|
||||||
var action_data: Variant = player_input.get("action_data")
|
var action_data: Variant = player_input.get("action_data")
|
||||||
if action_data is Dictionary:
|
if action_data is Dictionary:
|
||||||
facing = str(action_data.get("facing", ""))
|
facing = str(action_data.get("facing", ""))
|
||||||
if not facing.is_empty():
|
if not facing.is_empty():
|
||||||
_test_facing = facing
|
harness.process_facing(facing)
|
||||||
else:
|
else:
|
||||||
_test_input_queue.append(wire_name)
|
harness.process_input(wire_name)
|
||||||
return OK
|
return OK
|
||||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||||
if action_name.is_empty():
|
if action_name.is_empty():
|
||||||
# action_enum_to_wire already emits push_warning for invalid actions
|
|
||||||
return ERR_INVALID_PARAMETER
|
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 tick: int = GameState.current_tick
|
||||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
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")
|
var action_data: Variant = player_input.get("action_data")
|
||||||
if action_data != null:
|
if action_data != null:
|
||||||
entry["action_data"] = action_data
|
entry["action_data"] = action_data
|
||||||
@@ -208,13 +308,13 @@ func send_input(player_input: Dictionary) -> Error:
|
|||||||
return OK
|
return OK
|
||||||
|
|
||||||
# Poll for snapshot from simulation.
|
# 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:
|
func poll_snapshot() -> Variant:
|
||||||
if state != ConnectionState.CONNECTED:
|
if state != ConnectionState.CONNECTED:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
if test_mode:
|
if test_mode:
|
||||||
var snapshot = _test_snapshot()
|
var snapshot = harness.snapshot()
|
||||||
snapshot_received.emit(snapshot)
|
snapshot_received.emit(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
@@ -252,6 +352,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
|||||||
if old_conv_ended.size() > 0:
|
if old_conv_ended.size() > 0:
|
||||||
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
||||||
snapshot["conversation_ended"] = old_conv_ended + new_conv_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
|
_last_snapshot = snapshot
|
||||||
|
|
||||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||||
@@ -260,6 +363,9 @@ func drain_outbound() -> Array[Dictionary]:
|
|||||||
_outbound_buffer.clear()
|
_outbound_buffer.clear()
|
||||||
return inputs
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
# -- Wire protocol mapping -----------------------------------------------------
|
||||||
|
|
||||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
# 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.
|
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||||
static func action_enum_to_wire(action: int) -> String:
|
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)
|
return "SetFacing" # D-054: facing octant update (no movement)
|
||||||
InputMapper.Action.TELEPORT_HUB:
|
InputMapper.Action.TELEPORT_HUB:
|
||||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
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)
|
push_warning("SimBridge: unknown action enum %s" % action)
|
||||||
return ""
|
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"
|
|
||||||
|
|||||||
@@ -49,44 +49,6 @@ func reload() -> void:
|
|||||||
|
|
||||||
## Parse YAML with arbitrary nesting depth.
|
## Parse YAML with arbitrary nesting depth.
|
||||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||||
|
## Delegates to YamlParser.parse_flat() (#560).
|
||||||
static func _parse_yaml(text: String) -> Dictionary:
|
static func _parse_yaml(text: String) -> Dictionary:
|
||||||
var strings := {}
|
return YamlParser.parse_flat(text)
|
||||||
var stack: Array = [] # [[indent, key], ...]
|
|
||||||
for line in text.split("\n"):
|
|
||||||
var stripped := line.strip_edges(false, true)
|
|
||||||
if stripped.is_empty() or stripped.begins_with("#"):
|
|
||||||
continue
|
|
||||||
var indent := line.length() - line.lstrip(" ").length()
|
|
||||||
var content := stripped.strip_edges()
|
|
||||||
var colon_pos := content.find(":")
|
|
||||||
if colon_pos < 0:
|
|
||||||
continue
|
|
||||||
var key := content.substr(0, colon_pos).strip_edges()
|
|
||||||
var val := content.substr(colon_pos + 1).strip_edges()
|
|
||||||
# Trailing comment without a value — treat as section header
|
|
||||||
if val.begins_with("#"):
|
|
||||||
val = ""
|
|
||||||
# Pop sections at same or deeper indent
|
|
||||||
while stack.size() > 0 and stack.back()[0] >= indent:
|
|
||||||
stack.pop_back()
|
|
||||||
if val.is_empty():
|
|
||||||
# Section header — push onto stack
|
|
||||||
stack.push_back([indent, key])
|
|
||||||
else:
|
|
||||||
# Leaf value — extract from quotes or strip inline comment
|
|
||||||
if val.begins_with("\""):
|
|
||||||
var end_quote := val.find("\"", 1)
|
|
||||||
if end_quote > 0:
|
|
||||||
val = val.substr(1, end_quote - 1)
|
|
||||||
else:
|
|
||||||
val = val.substr(1)
|
|
||||||
else:
|
|
||||||
var comment_pos := val.find(" #")
|
|
||||||
if comment_pos >= 0:
|
|
||||||
val = val.substr(0, comment_pos).strip_edges()
|
|
||||||
var dotted_key := ""
|
|
||||||
for entry in stack:
|
|
||||||
dotted_key += entry[1] + "."
|
|
||||||
dotted_key += key
|
|
||||||
strings[dotted_key] = val
|
|
||||||
return strings
|
|
||||||
|
|||||||
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
# -- YAML parsing --------------------------------------------------------------
|
||||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
|
||||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
|
||||||
#
|
|
||||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
|
||||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
|
||||||
|
|
||||||
func _load_checklist_file(path: String) -> Dictionary:
|
func _load_checklist_file(path: String) -> Dictionary:
|
||||||
if not FileAccess.file_exists(path):
|
if not FileAccess.file_exists(path):
|
||||||
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
|
|||||||
return parse_checklist_yaml(text)
|
return parse_checklist_yaml(text)
|
||||||
|
|
||||||
|
|
||||||
|
## Delegates to YamlParser.parse() (#560).
|
||||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||||
var result := {}
|
return YamlParser.parse(text)
|
||||||
var conditions: Array = []
|
|
||||||
var current_item: Dictionary = {}
|
|
||||||
var in_conditions := false
|
|
||||||
|
|
||||||
for line in text.split("\n"):
|
|
||||||
var stripped := line.strip_edges(false, true)
|
|
||||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
var indent := line.length() - line.lstrip(" ").length()
|
|
||||||
var content := stripped.strip_edges()
|
|
||||||
|
|
||||||
# Detect conditions: array header
|
|
||||||
if content == "conditions:":
|
|
||||||
in_conditions = true
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not in_conditions:
|
|
||||||
# Top-level key: value
|
|
||||||
var colon := content.find(":")
|
|
||||||
if colon >= 0:
|
|
||||||
var key := content.substr(0, colon).strip_edges()
|
|
||||||
var val_str := content.substr(colon + 1).strip_edges()
|
|
||||||
result[key] = _parse_value(val_str)
|
|
||||||
else:
|
|
||||||
if content.begins_with("- "):
|
|
||||||
# New array item — flush previous
|
|
||||||
if not current_item.is_empty():
|
|
||||||
conditions.append(current_item)
|
|
||||||
current_item = {}
|
|
||||||
var rest := content.substr(2).strip_edges()
|
|
||||||
var colon := rest.find(":")
|
|
||||||
if colon >= 0:
|
|
||||||
var key := rest.substr(0, colon).strip_edges()
|
|
||||||
var val_str := rest.substr(colon + 1).strip_edges()
|
|
||||||
current_item[key] = _parse_value(val_str)
|
|
||||||
elif indent >= 2 and not current_item.is_empty():
|
|
||||||
# Continuation of current array item
|
|
||||||
var colon := content.find(":")
|
|
||||||
if colon >= 0:
|
|
||||||
var key := content.substr(0, colon).strip_edges()
|
|
||||||
var val_str := content.substr(colon + 1).strip_edges()
|
|
||||||
current_item[key] = _parse_value(val_str)
|
|
||||||
elif indent == 0:
|
|
||||||
# Back to top level — shouldn't happen in valid checklist YAML
|
|
||||||
in_conditions = false
|
|
||||||
if not current_item.is_empty():
|
|
||||||
conditions.append(current_item)
|
|
||||||
current_item = {}
|
|
||||||
var colon := content.find(":")
|
|
||||||
if colon >= 0:
|
|
||||||
var key := content.substr(0, colon).strip_edges()
|
|
||||||
var val_str := content.substr(colon + 1).strip_edges()
|
|
||||||
result[key] = _parse_value(val_str)
|
|
||||||
|
|
||||||
# Flush last item
|
|
||||||
if not current_item.is_empty():
|
|
||||||
conditions.append(current_item)
|
|
||||||
|
|
||||||
if not conditions.is_empty():
|
|
||||||
result["conditions"] = conditions
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
static func _parse_value(val: String) -> Variant:
|
|
||||||
if val.is_empty():
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Strip inline comments (not inside quotes)
|
|
||||||
if not val.begins_with("\""):
|
|
||||||
var comment_pos := val.find(" #")
|
|
||||||
if comment_pos >= 0:
|
|
||||||
val = val.substr(0, comment_pos).strip_edges()
|
|
||||||
|
|
||||||
# Quoted string
|
|
||||||
if val.begins_with("\""):
|
|
||||||
var end_quote := val.find("\"", 1)
|
|
||||||
if end_quote > 0:
|
|
||||||
return val.substr(1, end_quote - 1)
|
|
||||||
return val.substr(1)
|
|
||||||
|
|
||||||
# Boolean
|
|
||||||
if val == "true":
|
|
||||||
return true
|
|
||||||
if val == "false":
|
|
||||||
return false
|
|
||||||
|
|
||||||
# Float (contains decimal point)
|
|
||||||
if val.contains(".") and val.is_valid_float():
|
|
||||||
return val.to_float()
|
|
||||||
|
|
||||||
# Integer
|
|
||||||
if val.is_valid_int():
|
|
||||||
return val.to_int()
|
|
||||||
|
|
||||||
# Plain string
|
|
||||||
return val
|
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
|||||||
# two columns of text comfortably, leaves world game visible alongside.
|
# two columns of text comfortably, leaves world game visible alongside.
|
||||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||||
|
|
||||||
|
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||||
|
static func format_game_time(time_of_day: int) -> String:
|
||||||
|
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||||
|
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||||
|
|
||||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||||
|
|
||||||
|
|||||||
+145
-70
@@ -14,6 +14,10 @@ extends Node2D
|
|||||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
@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 debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||||
@@ -29,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
|
|||||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||||
|
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||||
|
|
||||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||||
|
|
||||||
@@ -61,11 +66,51 @@ func _ready() -> void:
|
|||||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||||
|
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
||||||
|
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
|
||||||
|
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
|
||||||
|
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
|
||||||
|
|
||||||
# #496: Print gauntlet session summary on disconnect
|
# #496: Print gauntlet session summary on disconnect
|
||||||
if gauntlet_hud:
|
if gauntlet_hud:
|
||||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||||
|
|
||||||
|
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
|
||||||
|
_router = SnapshotEventRouter.new()
|
||||||
|
# Always-run: child nodes that update from GameState on every snapshot tick.
|
||||||
|
if world_renderer:
|
||||||
|
_router.register_always(world_renderer.update_from_state)
|
||||||
|
_router.register_always(_propagate_insert_state)
|
||||||
|
_router.register_always(_update_interaction_list)
|
||||||
|
if inventory_grid:
|
||||||
|
_router.register_always(inventory_grid.update_from_state)
|
||||||
|
if stance_indicator:
|
||||||
|
_router.register_always(stance_indicator.update_from_state)
|
||||||
|
if fog_entities:
|
||||||
|
_router.register_always(fog_entities.update_from_state)
|
||||||
|
_router.register_always(_play_recognition_chimes)
|
||||||
|
if gauntlet_hud:
|
||||||
|
_router.register_always(gauntlet_hud.update_from_state)
|
||||||
|
if checklist_overlay:
|
||||||
|
_router.register_always(checklist_overlay.update_from_state)
|
||||||
|
if time_display:
|
||||||
|
_router.register_always(time_display.update_from_state)
|
||||||
|
if journal_panel:
|
||||||
|
_router.register_always(journal_panel.update_from_state)
|
||||||
|
if debug_overlay:
|
||||||
|
_router.register_always(debug_overlay.update_from_state)
|
||||||
|
_router.register_always(_play_close_sound_events)
|
||||||
|
_router.register_always(_update_zone)
|
||||||
|
_router.register_always(_update_listening_focus)
|
||||||
|
_router.register_always(_consume_examine_result)
|
||||||
|
# Keyed: consume methods guarded by specific snapshot fields.
|
||||||
|
_router.register("current_monologue", _consume_monologue)
|
||||||
|
_router.register("current_dialogue", _consume_dialogue)
|
||||||
|
_router.register("conversation_events", _consume_conversation_events)
|
||||||
|
_router.register("conversation_ended", _consume_conversation_ended)
|
||||||
|
_router.register("dialogue_response", _consume_dialogue_response)
|
||||||
|
_router.register("save_result", _consume_save_result)
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
# Main game loop: poll snapshot, apply state, flush input
|
# Main game loop: poll snapshot, apply state, flush input
|
||||||
@@ -85,76 +130,9 @@ func _process(delta: float) -> void:
|
|||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||||
_camera_anchored = true
|
_camera_anchored = true
|
||||||
|
|
||||||
# Update renderers with new state
|
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
||||||
world_renderer.update_from_state()
|
_router.dispatch(snapshot)
|
||||||
|
|
||||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
|
||||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
|
||||||
var insert_state := GameState.insert_active
|
|
||||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
|
||||||
cursor_renderer.set_insert_active(insert_state)
|
|
||||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
|
||||||
interaction_list.set_insert_active(insert_state)
|
|
||||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
|
||||||
interaction_prompt.set_insert_active(insert_state)
|
|
||||||
|
|
||||||
# D-057: Update interaction list from game state
|
|
||||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
|
||||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
|
||||||
if interaction_list.is_showing():
|
|
||||||
interaction_list._hide()
|
|
||||||
else:
|
|
||||||
interaction_list.update_from_state()
|
|
||||||
|
|
||||||
# D-065: Update inventory grid
|
|
||||||
if inventory_grid and inventory_grid.has_method("update_from_state"):
|
|
||||||
inventory_grid.update_from_state()
|
|
||||||
|
|
||||||
# D-053: Update stance indicator
|
|
||||||
if stance_indicator and stance_indicator.has_method("update_from_state"):
|
|
||||||
stance_indicator.update_from_state()
|
|
||||||
|
|
||||||
# D-059/D-060: Update fog entity visualization (#431)
|
|
||||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
|
||||||
fog_entities.update_from_state()
|
|
||||||
|
|
||||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
|
||||||
_play_recognition_chimes()
|
|
||||||
|
|
||||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
|
||||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
|
||||||
gauntlet_hud.update_from_state()
|
|
||||||
|
|
||||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
|
||||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
|
||||||
checklist_overlay.update_from_state()
|
|
||||||
|
|
||||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
|
||||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
|
||||||
debug_overlay.update_from_state()
|
|
||||||
|
|
||||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
|
||||||
_play_close_sound_events()
|
|
||||||
|
|
||||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
|
||||||
_update_zone()
|
|
||||||
|
|
||||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
|
||||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
|
||||||
_update_listening_focus()
|
|
||||||
|
|
||||||
# Show monologue if server sent one this tick (#414)
|
|
||||||
_consume_monologue()
|
|
||||||
|
|
||||||
# D-061: Show dialogue if server sent one this tick (#434)
|
|
||||||
_consume_dialogue()
|
|
||||||
|
|
||||||
# #535: Consume overheard conversation events and responses
|
|
||||||
_consume_conversation_events()
|
|
||||||
_consume_conversation_ended()
|
|
||||||
_consume_dialogue_response()
|
|
||||||
|
|
||||||
# Track camera to player (D-015: locked, fixed-north).
|
# Track camera to player (D-015: locked, fixed-north).
|
||||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||||
@@ -179,6 +157,10 @@ func _process(delta: float) -> void:
|
|||||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||||
bug_report_dialog.start_capture()
|
bug_report_dialog.start_capture()
|
||||||
continue
|
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
|
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||||
if input.action == InputMapper.Action.OPEN_MENU:
|
if input.action == InputMapper.Action.OPEN_MENU:
|
||||||
if settings_dialog:
|
if settings_dialog:
|
||||||
@@ -223,6 +205,32 @@ func _process(delta: float) -> void:
|
|||||||
_pending_record_inputs.clear()
|
_pending_record_inputs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||||
|
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||||
|
func _propagate_insert_state() -> void:
|
||||||
|
var insert_state := GameState.insert_active
|
||||||
|
if cursor_renderer:
|
||||||
|
cursor_renderer.set_insert_active(insert_state)
|
||||||
|
if interaction_list:
|
||||||
|
interaction_list.set_insert_active(insert_state)
|
||||||
|
if interaction_prompt:
|
||||||
|
interaction_prompt.set_insert_active(insert_state)
|
||||||
|
if minimap:
|
||||||
|
minimap.set_insert_active(insert_state)
|
||||||
|
|
||||||
|
|
||||||
|
# D-057: Update interaction list from game state.
|
||||||
|
# Suppress during dialogue — player is in conversation, verb list is noise.
|
||||||
|
func _update_interaction_list() -> void:
|
||||||
|
if not interaction_list:
|
||||||
|
return
|
||||||
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
|
if interaction_list.is_showing():
|
||||||
|
interaction_list.hide_list()
|
||||||
|
else:
|
||||||
|
interaction_list.update_from_state()
|
||||||
|
|
||||||
|
|
||||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||||
@@ -313,6 +321,9 @@ func _consume_dialogue() -> void:
|
|||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
return
|
return
|
||||||
_last_dialogue_tick = GameState.current_tick
|
_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
|
var dlg: Dictionary = GameState.current_dialogue
|
||||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||||
@@ -357,6 +368,27 @@ func _consume_dialogue_response() -> void:
|
|||||||
GameState.dialogue_response = null
|
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
|
# D-061: Handle dialogue option selection → send to server
|
||||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||||
SimBridge.send_input({
|
SimBridge.send_input({
|
||||||
@@ -408,6 +440,22 @@ func _on_dialogue_dismissed() -> void:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
|
||||||
|
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
|
||||||
|
func _on_dialogue_state_changed(active: bool) -> void:
|
||||||
|
GameState.dialogue_active = active
|
||||||
|
|
||||||
|
|
||||||
|
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
|
||||||
|
func _on_audio_dip_requested(profile: String) -> void:
|
||||||
|
AudioManager.apply_dip(profile)
|
||||||
|
|
||||||
|
|
||||||
|
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
|
||||||
|
func _on_audio_dip_cleared() -> void:
|
||||||
|
AudioManager.clear_dip()
|
||||||
|
|
||||||
|
|
||||||
# #496: Finalize gauntlet stats on disconnect
|
# #496: Finalize gauntlet stats on disconnect
|
||||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||||
@@ -451,6 +499,33 @@ func _teleport_transition() -> void:
|
|||||||
tween.tween_callback(_flash_rect.queue_free)
|
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.
|
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||||
func _screen_flash(color: Color, duration: float) -> void:
|
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.
|
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||||
## Reject snapshots where version != this value.
|
## Reject snapshots where version != this value.
|
||||||
const PROTOCOL_VERSION: int = 13
|
const PROTOCOL_VERSION: int = 15
|
||||||
|
|
||||||
|
|
||||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||||
@@ -206,6 +206,99 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"target_id": int(raw_end.get("target_id", 0)),
|
"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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||||
|
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
||||||
|
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||||
|
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||||
|
var stationary_ticks: Variant = null
|
||||||
|
var raw_st: Variant = raw.get("stationary_ticks")
|
||||||
|
if raw_st != null:
|
||||||
|
stationary_ticks = int(raw_st)
|
||||||
|
|
||||||
|
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
|
||||||
|
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
|
||||||
|
# When server populates this, client-side tile iteration fallback in game_state.gd
|
||||||
|
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||||
|
var zone_id: Variant = null
|
||||||
|
var raw_zid: Variant = raw.get("zone_id")
|
||||||
|
if raw_zid is String:
|
||||||
|
zone_id = raw_zid
|
||||||
|
|
||||||
|
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||||
|
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||||
|
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||||
|
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 {
|
return {
|
||||||
"tick": tick,
|
"tick": tick,
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
@@ -223,6 +316,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"pending_recognitions": pending_recognitions,
|
"pending_recognitions": pending_recognitions,
|
||||||
"conversation_events": conversation_events,
|
"conversation_events": conversation_events,
|
||||||
"conversation_ended": conversation_ended,
|
"conversation_ended": conversation_ended,
|
||||||
|
"poi_list": poi_list,
|
||||||
|
"examine_result": examine_result,
|
||||||
|
"player_knowledge": player_knowledge,
|
||||||
|
"save_result": save_result,
|
||||||
|
"stationary_ticks": stationary_ticks,
|
||||||
|
"zone_id": zone_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
class_name SnapshotEventRouter
|
||||||
|
## Routes snapshot fields to registered handlers (D-020, #559).
|
||||||
|
##
|
||||||
|
## Decouples main.gd from knowing which child node handles which snapshot field.
|
||||||
|
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
|
||||||
|
##
|
||||||
|
## Two handler types:
|
||||||
|
## - Keyed: called only when the snapshot contains a specific field.
|
||||||
|
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
|
||||||
|
##
|
||||||
|
## All handlers are zero-argument callables — they read from GameState directly.
|
||||||
|
## This preserves GameState as the single source of truth post-apply_snapshot().
|
||||||
|
|
||||||
|
## Keyed handlers: field_name → Array[Callable]
|
||||||
|
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
|
||||||
|
var _keyed: Dictionary = {} # String → Array[Callable]
|
||||||
|
|
||||||
|
## Always handlers: called every dispatch regardless of snapshot content.
|
||||||
|
var _always: Array[Callable] = []
|
||||||
|
|
||||||
|
|
||||||
|
## Register a handler for a specific snapshot field key.
|
||||||
|
## Handler is called (with no arguments) when snapshot.has(field) is true.
|
||||||
|
## Multiple handlers per field are supported — they run in registration order.
|
||||||
|
func register(field: String, handler: Callable) -> void:
|
||||||
|
if not _keyed.has(field):
|
||||||
|
_keyed[field] = []
|
||||||
|
_keyed[field].append(handler)
|
||||||
|
|
||||||
|
|
||||||
|
## Register a handler that runs every dispatch tick (not keyed to a field).
|
||||||
|
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
|
||||||
|
func register_always(handler: Callable) -> void:
|
||||||
|
_always.append(handler)
|
||||||
|
|
||||||
|
|
||||||
|
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
|
||||||
|
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
|
||||||
|
func dispatch(snapshot: Dictionary) -> void:
|
||||||
|
for handler in _always:
|
||||||
|
handler.call()
|
||||||
|
for field in _keyed:
|
||||||
|
if snapshot.has(field):
|
||||||
|
for handler in _keyed[field]:
|
||||||
|
handler.call()
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
extends Control
|
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 HEADER_COLOR := Color("#e8c547")
|
||||||
const LABEL_COLOR := Color("#8890a0")
|
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 FONT_SIZE := 12
|
||||||
const LINE_HEIGHT := 16
|
const LINE_HEIGHT := 16
|
||||||
const PADDING := Vector2(10, 8)
|
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 _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:
|
func _ready() -> void:
|
||||||
|
_dev_mode = OS.is_debug_build()
|
||||||
visible = false
|
visible = false
|
||||||
_cached_font = ThemeDB.fallback_font
|
_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:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not _dev_mode:
|
||||||
|
return
|
||||||
if event.is_action_pressed("debug_overlay"):
|
if event.is_action_pressed("debug_overlay"):
|
||||||
visible = not visible
|
visible = not visible
|
||||||
if visible:
|
if visible:
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func update_from_state() -> void:
|
func update_from_state() -> void:
|
||||||
if not visible:
|
if not _dev_mode or not visible:
|
||||||
return
|
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()
|
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:
|
func _draw() -> void:
|
||||||
if not visible:
|
if not visible:
|
||||||
return
|
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
|
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 left_lines: Array = []
|
||||||
var right_lines: Array = []
|
var right_lines: Array = []
|
||||||
|
|
||||||
@@ -71,7 +200,10 @@ func _draw() -> void:
|
|||||||
left_lines.append(["mode", mode_str])
|
left_lines.append(["mode", mode_str])
|
||||||
right_lines.append(["gauntlet", gauntlet_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_label_w: float = 0.0
|
||||||
var left_value_w: float = 0.0
|
var left_value_w: float = 0.0
|
||||||
var right_label_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 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 box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
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)
|
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||||
|
|
||||||
# Header
|
|
||||||
var y: float = PADDING.y + FONT_SIZE
|
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)
|
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||||
y += LINE_HEIGHT
|
y += LINE_HEIGHT
|
||||||
|
|
||||||
# Data lines (two columns)
|
|
||||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||||
for i in range(line_count):
|
for i in range(line_count):
|
||||||
if i < left_lines.size():
|
if i < left_lines.size():
|
||||||
var lbl: String = left_lines[i][0] + ": "
|
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||||
var val: String = left_lines[i][1]
|
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||||
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)
|
|
||||||
if i < right_lines.size():
|
if i < right_lines.size():
|
||||||
var lbl: String = right_lines[i][0] + ": "
|
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||||
var val: String = right_lines[i][1]
|
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||||
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)
|
|
||||||
y += LINE_HEIGHT
|
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,163 @@
|
|||||||
|
class_name YamlParser
|
||||||
|
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
|
||||||
|
##
|
||||||
|
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
|
||||||
|
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
|
||||||
|
## (as UIStrings._parse_yaml() requires).
|
||||||
|
##
|
||||||
|
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
|
||||||
|
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
|
||||||
|
##
|
||||||
|
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
|
||||||
|
|
||||||
|
|
||||||
|
## Parse YAML text into a hierarchical Dictionary.
|
||||||
|
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
|
||||||
|
## Values are type-inferred: bool, int, float, or String.
|
||||||
|
static func parse(text: String) -> Dictionary:
|
||||||
|
var root: Dictionary = {}
|
||||||
|
# Stack: [{indent: int, key: String}] — path of open section headers
|
||||||
|
var stack: Array = []
|
||||||
|
# Array state
|
||||||
|
var current_array: Variant = null # Array being built, or null
|
||||||
|
var current_item: Variant = null # Dict being built for current array item, or null
|
||||||
|
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||||
|
|
||||||
|
for raw_line in text.split("\n"):
|
||||||
|
var stripped := raw_line.strip_edges(false, true)
|
||||||
|
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||||
|
continue
|
||||||
|
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
|
||||||
|
var content: String = stripped.strip_edges()
|
||||||
|
|
||||||
|
# --- Array item (- key: value) ---
|
||||||
|
if content.begins_with("- "):
|
||||||
|
# First item: convert parent section's {} placeholder to []
|
||||||
|
if current_array == null and stack.size() > 0:
|
||||||
|
var parent := _node_at(root, stack, true)
|
||||||
|
var arr_key: String = stack.back()["key"]
|
||||||
|
var new_arr: Array = []
|
||||||
|
parent[arr_key] = new_arr
|
||||||
|
current_array = new_arr
|
||||||
|
array_parent_indent = stack.back()["indent"]
|
||||||
|
# Flush previous item and start a new one
|
||||||
|
if current_item != null:
|
||||||
|
current_array.append(current_item)
|
||||||
|
current_item = {}
|
||||||
|
var rest: String = content.substr(2).strip_edges()
|
||||||
|
var colon: int = rest.find(":")
|
||||||
|
if colon >= 0:
|
||||||
|
var k: String = rest.substr(0, colon).strip_edges()
|
||||||
|
var v: String = rest.substr(colon + 1).strip_edges()
|
||||||
|
current_item[k] = _parse_value(v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- Continuation line within current array item ---
|
||||||
|
if current_array != null and indent > array_parent_indent:
|
||||||
|
var colon: int = content.find(":")
|
||||||
|
if colon >= 0 and current_item != null:
|
||||||
|
var k: String = content.substr(0, colon).strip_edges()
|
||||||
|
var v: String = content.substr(colon + 1).strip_edges()
|
||||||
|
current_item[k] = _parse_value(v)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- End of array (indent has returned to array level or above) ---
|
||||||
|
if current_array != null:
|
||||||
|
if current_item != null:
|
||||||
|
current_array.append(current_item)
|
||||||
|
current_item = null
|
||||||
|
current_array = null
|
||||||
|
array_parent_indent = -1
|
||||||
|
if stack.size() > 0:
|
||||||
|
stack.pop_back() # pop the array-owning key
|
||||||
|
|
||||||
|
# --- Regular key: value or section header ---
|
||||||
|
var colon: int = content.find(":")
|
||||||
|
if colon < 0:
|
||||||
|
continue
|
||||||
|
var key: String = content.substr(0, colon).strip_edges()
|
||||||
|
var val_str: String = content.substr(colon + 1).strip_edges()
|
||||||
|
|
||||||
|
# Pop sections at the same or deeper indent (we're back at a shallower level)
|
||||||
|
while stack.size() > 0 and stack.back()["indent"] >= indent:
|
||||||
|
stack.pop_back()
|
||||||
|
|
||||||
|
var node: Dictionary = _node_at(root, stack, false)
|
||||||
|
|
||||||
|
if val_str.is_empty() or val_str.begins_with("#"):
|
||||||
|
# Section header — create nested dict (may become Array if - items follow)
|
||||||
|
node[key] = {}
|
||||||
|
stack.push_back({"indent": indent, "key": key})
|
||||||
|
else:
|
||||||
|
node[key] = _parse_value(val_str)
|
||||||
|
|
||||||
|
# Flush the last array item if the file ended inside an array
|
||||||
|
if current_array != null and current_item != null:
|
||||||
|
current_array.append(current_item)
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
## Convenience: parse text and flatten to dotted-key format in one call.
|
||||||
|
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
|
||||||
|
static func parse_flat(text: String) -> Dictionary:
|
||||||
|
return flatten(parse(text))
|
||||||
|
|
||||||
|
|
||||||
|
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
|
||||||
|
## {"a": {"b": "v"}} → {"a.b": "v"}
|
||||||
|
## Arrays are skipped — dotted-key format does not represent them.
|
||||||
|
## All values are converted to String (UIStrings stores display text, not typed data).
|
||||||
|
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
|
||||||
|
var result: Dictionary = {}
|
||||||
|
for k in d:
|
||||||
|
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
|
||||||
|
var v = d[k]
|
||||||
|
if v is Dictionary:
|
||||||
|
result.merge(flatten(v, full_key))
|
||||||
|
elif not v is Array:
|
||||||
|
result[full_key] = str(v)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Parse a single YAML value string into a typed GDScript value.
|
||||||
|
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
|
||||||
|
static func _parse_value(val: String) -> Variant:
|
||||||
|
if val.is_empty():
|
||||||
|
return ""
|
||||||
|
# Strip inline comment outside quotes
|
||||||
|
if not val.begins_with("\""):
|
||||||
|
var comment_pos: int = val.find(" #")
|
||||||
|
if comment_pos >= 0:
|
||||||
|
val = val.substr(0, comment_pos).strip_edges()
|
||||||
|
# Quoted string — extract content between quotes
|
||||||
|
if val.begins_with("\""):
|
||||||
|
var end_quote: int = val.find("\"", 1)
|
||||||
|
if end_quote > 0:
|
||||||
|
return val.substr(1, end_quote - 1)
|
||||||
|
return val.substr(1)
|
||||||
|
# Boolean
|
||||||
|
if val == "true": return true
|
||||||
|
if val == "false": return false
|
||||||
|
# Float (must have decimal point)
|
||||||
|
if val.contains(".") and val.is_valid_float():
|
||||||
|
return val.to_float()
|
||||||
|
# Integer
|
||||||
|
if val.is_valid_int():
|
||||||
|
return val.to_int()
|
||||||
|
# Plain string
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
## Navigate root following the stack key path.
|
||||||
|
## parent=true: navigate one level less (returns the parent node, not the leaf).
|
||||||
|
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
|
||||||
|
var node: Dictionary = root
|
||||||
|
var depth: int = stack.size() - (1 if parent else 0)
|
||||||
|
for i in range(depth):
|
||||||
|
var k: String = stack[i]["key"]
|
||||||
|
if node.has(k) and node[k] is Dictionary:
|
||||||
|
node = node[k]
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return node
|
||||||
@@ -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,672 @@
|
|||||||
|
## 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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
|
||||||
|
# directly mutating GameState or calling AudioManager.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_dialogue_state_changed_emits_true_on_show() -> void:
|
||||||
|
## D-020: show_dialogue() must emit dialogue_state_changed(true).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var received: Array = []
|
||||||
|
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||||
|
assert_bool(received.has(true)).override_failure_message(
|
||||||
|
"dialogue_state_changed(true) must be emitted on show_dialogue"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_dialogue_state_changed_emits_false_on_hide() -> void:
|
||||||
|
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var received: Array = []
|
||||||
|
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||||
|
box.hide_dialogue()
|
||||||
|
assert_bool(received.has(false)).override_failure_message(
|
||||||
|
"dialogue_state_changed(false) must be emitted on hide_dialogue"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
|
||||||
|
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var received: Array = []
|
||||||
|
box.audio_dip_requested.connect(func(profile): received.append(profile))
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||||
|
assert_bool(received.has("dialogue")).override_failure_message(
|
||||||
|
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_audio_dip_cleared_emits_on_hide() -> void:
|
||||||
|
## D-020: hide_dialogue() must emit audio_dip_cleared.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var cleared := [false]
|
||||||
|
box.audio_dip_cleared.connect(func(): cleared[0] = true)
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||||
|
box.hide_dialogue()
|
||||||
|
assert_bool(cleared[0]).override_failure_message(
|
||||||
|
"audio_dip_cleared must be emitted on hide_dialogue"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_no_direct_game_state_mutation() -> void:
|
||||||
|
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
|
||||||
|
## After show_dialogue, GameState.dialogue_active should remain unchanged
|
||||||
|
## (only the coordinator updates it via signal handler).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||||
|
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||||
|
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
|
||||||
|
).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
GameState.dialogue_active = false
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
## Sprint 20 #558: dialogue_box.gd decoupling tests.
|
||||||
|
##
|
||||||
|
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
|
||||||
|
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
|
||||||
|
##
|
||||||
|
## D-030: fixture-based, server-free, no subprocess required.
|
||||||
|
class_name TestDialogueSprint20
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func _make_dialogue_box() -> Control:
|
||||||
|
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||||
|
push_warning("TestDialogueSprint20: dialogue_box.tscn not found — scene tests skipped")
|
||||||
|
return null
|
||||||
|
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
|
||||||
|
|
||||||
|
# -- dialogue_state_changed signal -------------------------------------------
|
||||||
|
|
||||||
|
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
|
||||||
|
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
var received: Variant = null
|
||||||
|
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
assert_that(received).override_failure_message(
|
||||||
|
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
|
||||||
|
).is_equal(true)
|
||||||
|
|
||||||
|
|
||||||
|
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
|
||||||
|
## Without a connected handler, GameState.dialogue_active must stay false.
|
||||||
|
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||||
|
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
|
||||||
|
).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
|
||||||
|
## hide_dialogue() must emit dialogue_state_changed(false).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
var received: Variant = null
|
||||||
|
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||||
|
box.hide_dialogue()
|
||||||
|
assert_that(received).override_failure_message(
|
||||||
|
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
|
||||||
|
).is_equal(false)
|
||||||
|
|
||||||
|
|
||||||
|
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
|
||||||
|
|
||||||
|
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
|
||||||
|
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
var received_profile: Variant = null
|
||||||
|
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
assert_that(received_profile).override_failure_message(
|
||||||
|
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
|
||||||
|
).is_equal("dialogue")
|
||||||
|
|
||||||
|
|
||||||
|
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
|
||||||
|
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
|
||||||
|
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
var dip_before := AudioManager.get_active_dip()
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
var dip_after := AudioManager.get_active_dip()
|
||||||
|
assert_str(dip_after).override_failure_message(
|
||||||
|
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
|
||||||
|
).is_equal(dip_before)
|
||||||
|
|
||||||
|
|
||||||
|
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
|
||||||
|
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
var cleared := false
|
||||||
|
box.audio_dip_cleared.connect(func(): cleared = true)
|
||||||
|
box.hide_dialogue()
|
||||||
|
assert_bool(cleared).override_failure_message(
|
||||||
|
"hide_dialogue() must emit audio_dip_cleared (#558)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_audio_dip_cleared_count_on_conversation_end() -> void:
|
||||||
|
## Verify audio_dip_cleared fires when conversation ends.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
var cleared_count := 0
|
||||||
|
box.audio_dip_cleared.connect(func(): cleared_count += 1)
|
||||||
|
box.show_dialogue("NPC", "Speak.", [])
|
||||||
|
box.hide_dialogue()
|
||||||
|
assert_int(cleared_count).override_failure_message(
|
||||||
|
"audio_dip_cleared must fire at least once when conversation ends (#558)"
|
||||||
|
).is_greater_equal(1)
|
||||||
|
|
||||||
|
|
||||||
|
# -- coordinator wiring verification -----------------------------------------
|
||||||
|
|
||||||
|
func test_signal_handler_wires_game_state() -> void:
|
||||||
|
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
|
||||||
|
## Verifies the coordinator pattern works end-to-end (D-020 #558).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
auto_free(box)
|
||||||
|
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
|
||||||
|
box.show_dialogue("NPC", "Hello.", [])
|
||||||
|
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||||
|
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
|
||||||
|
).is_true()
|
||||||
|
box.hide_dialogue()
|
||||||
|
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||||
|
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
|
||||||
|
).is_false()
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
|
||||||
|
## Tests for relationship color indicator in interaction_list.gd.
|
||||||
|
##
|
||||||
|
## Spec refs:
|
||||||
|
## D-033 (entity color = relationship to player)
|
||||||
|
## D-051 (diegetic insert display — insert-only overlay)
|
||||||
|
## D-057 (interaction list, z-layer 6)
|
||||||
|
##
|
||||||
|
## Phase 1 scope (2026-02-24):
|
||||||
|
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
|
||||||
|
## server protocol change (no known_attributes in wire protocol v13).
|
||||||
|
##
|
||||||
|
## Implementation: client/ui/interaction_list.gd
|
||||||
|
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
|
||||||
|
## `func _cache_entity_relationship() -> void`
|
||||||
|
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
|
||||||
|
class_name TestETalkOverlaySprint17
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.player_stance = ""
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.player_stance = ""
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_relationship_unknown_maps_to_teal() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
func test_relationship_friendly_maps_to_green() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
|
|
||||||
|
func test_relationship_poi_maps_to_amber() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
|
|
||||||
|
func test_relationship_hostile_maps_to_red() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
|
||||||
|
func test_relationship_known_falls_back_to_unknown_color() -> void:
|
||||||
|
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
|
||||||
|
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_unknown_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_known_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_friendly_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_poi_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_hostile_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# _relationship_color initial state
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_relationship_color_default_is_implant_dim() -> void:
|
||||||
|
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
|
||||||
|
var list = _make_list()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_cache_relationship_unknown_sets_unknown_color() -> void:
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_friendly_sets_green() -> void:
|
||||||
|
_setup_npc_interaction(2, "Friendly")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_poi_sets_amber() -> void:
|
||||||
|
_setup_npc_interaction(2, "PersonOfInterest")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_hostile_sets_red() -> void:
|
||||||
|
_setup_npc_interaction(2, "Hostile")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
|
||||||
|
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward",
|
||||||
|
}]
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
|
||||||
|
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_updates_when_relationship_changes() -> void:
|
||||||
|
# First call: Unknown
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
# Relationship shifts → re-cache picks up new value
|
||||||
|
_setup_npc_interaction(2, "Hostile")
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_targets_correct_entity_by_id() -> void:
|
||||||
|
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
|
||||||
|
# Entity 3 (Hostile) must not pollute the color
|
||||||
|
GameState.visible_entities = [
|
||||||
|
{
|
||||||
|
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
GameState.nearby_interactions = [
|
||||||
|
{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": 3, "entity_type": "Npc", "distance": 2,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
# interaction[0] = entity 2 (Friendly) → green bar
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_z_layer_is_insert_canvas() -> void:
|
||||||
|
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
|
||||||
|
var list = _make_list()
|
||||||
|
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Regression: interaction_list public API unaffected by #537 changes
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_list_hides_when_no_interactions() -> void:
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_false()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_shows_on_npc_interaction() -> void:
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_true()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_get_selected_verb_returns_first_verb() -> void:
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [
|
||||||
|
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||||
|
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
assert_that(list.get_selected_verb()).is_equal("Talk")
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_get_interaction_target_returns_entity_id() -> void:
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
|
||||||
|
}]
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 5, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.get_interaction_target()).is_equal(5)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_suppressed_when_insert_inactive() -> void:
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.set_insert_active(false)
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_false()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_hides_on_empty_verb_list() -> void:
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_false()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func skip_test_npc_name_displayed_when_known() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _make_list() -> Control:
|
||||||
|
var scene = load("res://ui/interaction_list.tscn")
|
||||||
|
var list = scene.instantiate()
|
||||||
|
add_child(list) # _ready() fires here — @onready var _vbox resolves
|
||||||
|
return list
|
||||||
|
|
||||||
|
|
||||||
|
## Set up GameState with a single NPC entity + matching interaction for tests.
|
||||||
|
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
|
||||||
|
}]
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
## Sprint 18 — Examine result display (#174)
|
||||||
|
## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation)
|
||||||
|
##
|
||||||
|
## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY.
|
||||||
|
## Positioned in InsertOverlay (CanvasLayer 10).
|
||||||
|
## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge).
|
||||||
|
##
|
||||||
|
## Tests run against live Stig implementation (examine_display.gd).
|
||||||
|
class_name TestExamineDisplaySprint18
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn"
|
||||||
|
|
||||||
|
func _make_examine_display() -> Control:
|
||||||
|
if not ResourceLoader.exists(EXAMINE_SCENE_PATH):
|
||||||
|
push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip")
|
||||||
|
return null
|
||||||
|
var node: Control = load(EXAMINE_SCENE_PATH).instantiate()
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func _make_result(overrides: Dictionary = {}) -> Dictionary:
|
||||||
|
var base: Dictionary = {
|
||||||
|
"entity_id": 42,
|
||||||
|
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||||
|
"confidence": "KnowsOf",
|
||||||
|
}
|
||||||
|
base.merge(overrides, true)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState: current_examine_result parsing
|
||||||
|
## (Now tests real implementation — not test-first stubs)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_field_exists() -> void:
|
||||||
|
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||||
|
"GameState must have 'current_examine_result' field (#174)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_by_default() -> void:
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()})
|
||||||
|
assert_that(GameState.current_examine_result).is_not_null()
|
||||||
|
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||||
|
## CONTRAST with player_knowledge: examine_result DOES clear each snapshot.
|
||||||
|
## The overlay must auto-dismiss — the server never re-sends the same result.
|
||||||
|
GameState.current_examine_result = _make_result()
|
||||||
|
GameState.apply_snapshot({"tick": 6})
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})})
|
||||||
|
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_confidence_survives_roundtrip() -> void:
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})})
|
||||||
|
assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_replaced_on_next_snapshot() -> void:
|
||||||
|
## Two examine results in sequence — second replaces first.
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})})
|
||||||
|
GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})})
|
||||||
|
assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay scene
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_examine_display_scene_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message(
|
||||||
|
"ExamineDisplay scene must exist at res://ui/examine_display.tscn"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_instantiates_without_crash() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_that(display).is_not_null()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_has_show_result_method() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.has_method("show_result")).override_failure_message(
|
||||||
|
"ExamineDisplay must have show_result(result: Dictionary) method"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_has_dismiss_method() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.has_method("dismiss")).override_failure_message(
|
||||||
|
"ExamineDisplay must have dismiss() method"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_has_is_active_method() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.has_method("is_active")).override_failure_message(
|
||||||
|
"ExamineDisplay must have is_active() method"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay behavior
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_examine_display_not_active_on_init() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"ExamineDisplay must start inactive (no result showing)"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_not_visible_on_init() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_bool(display.visible).override_failure_message(
|
||||||
|
"ExamineDisplay must start invisible"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_active_after_show_result() -> void:
|
||||||
|
## show_result() with valid text sets is_active() = true.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result())
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"show_result() with text must set is_active() = true"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_visible_after_show_result() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result())
|
||||||
|
assert_bool(display.visible).override_failure_message(
|
||||||
|
"show_result() must set visible = true"
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_empty_text_ignored() -> void:
|
||||||
|
## show_result() with empty text must not activate (D-041: no empty observations).
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"})
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"show_result() with empty text must not activate the display"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_inactive_after_dismiss() -> void:
|
||||||
|
## dismiss() immediately starts fade-out and sets _active = false.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result())
|
||||||
|
assert_bool(display.is_active()).is_true()
|
||||||
|
display.dismiss()
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"dismiss() must set is_active() = false immediately"
|
||||||
|
).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_dismiss_when_inactive_no_crash() -> void:
|
||||||
|
## dismiss() on an inactive display must be safe (no crash, no state corruption).
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.dismiss() # called when not active
|
||||||
|
assert_bool(display.is_active()).is_false()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_examine_display_show_replaces_previous() -> void:
|
||||||
|
## Second show_result() replaces first (only one result at a time).
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
display.show_result(_make_result({"text": "First observation."}))
|
||||||
|
display.show_result(_make_result({"text": "Second observation."}))
|
||||||
|
assert_bool(display.is_active()).override_failure_message(
|
||||||
|
"show_result() called twice must leave display active"
|
||||||
|
).is_true()
|
||||||
|
## Text label should reflect the second result
|
||||||
|
var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel")
|
||||||
|
if text_label is RichTextLabel:
|
||||||
|
assert_that(text_label.text).override_failure_message(
|
||||||
|
"Second show_result() must replace the displayed text"
|
||||||
|
).is_equal("Second observation.")
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay: DISMISS_DELAY within spec
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_dismiss_delay_within_spec() -> void:
|
||||||
|
## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_float(display.DISMISS_DELAY).override_failure_message(
|
||||||
|
"DISMISS_DELAY must be 4–6 seconds per spec"
|
||||||
|
).is_between(4.0, 6.0)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_confidence_alpha_dict_covers_all_levels() -> void:
|
||||||
|
## All four confidence levels must have alpha mappings.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||||
|
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
|
||||||
|
assert_bool(alpha_dict.has(level)).override_failure_message(
|
||||||
|
"CONFIDENCE_ALPHA must map '%s'" % level
|
||||||
|
).is_true()
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_confidence_alpha_direct_is_highest() -> void:
|
||||||
|
## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||||
|
assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message(
|
||||||
|
"Direct confidence must have alpha 1.0 (brightest)"
|
||||||
|
).is_equal_approx(1.0, 0.001)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_confidence_alpha_suspects_is_lowest() -> void:
|
||||||
|
## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||||
|
var suspects_alpha: float = alpha_dict.get("Suspects", 1.0)
|
||||||
|
var direct_alpha: float = alpha_dict.get("Direct", 0.0)
|
||||||
|
assert_float(suspects_alpha).override_failure_message(
|
||||||
|
"Suspects alpha must be less than Direct alpha (dimmer = less certain)"
|
||||||
|
).is_less(direct_alpha)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_confidence_alpha_all_values_valid() -> void:
|
||||||
|
## All alpha values must be in [0.0, 1.0].
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
for key in display.CONFIDENCE_ALPHA:
|
||||||
|
var alpha: float = display.CONFIDENCE_ALPHA[key]
|
||||||
|
assert_float(alpha).override_failure_message(
|
||||||
|
"CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha]
|
||||||
|
).is_between(0.0, 1.0)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-interactive: mouse_filter must be IGNORE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_examine_display_mouse_filter_ignore() -> void:
|
||||||
|
## ExamineDisplay is non-interactive — must not consume mouse events.
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_int(display.mouse_filter).override_failure_message(
|
||||||
|
"ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)"
|
||||||
|
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fade constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fade_in_is_short() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_float(display.FADE_IN).is_between(0.0, 0.5)
|
||||||
|
display.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fade_out_is_short() -> void:
|
||||||
|
var display := _make_examine_display()
|
||||||
|
if display == null: return
|
||||||
|
assert_float(display.FADE_OUT).is_between(0.0, 0.5)
|
||||||
|
display.queue_free()
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
## 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._prev_player_position = Vector2(-1e9, -1e9)
|
||||||
|
GameState.current_zone_id = ""
|
||||||
|
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 ticks: server-authoritative (D-020/D-071) -----------------
|
||||||
|
|
||||||
|
func test_stationary_ticks_from_server_snapshot() -> void:
|
||||||
|
## D-020: When server sends stationary_ticks, client reads it directly.
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "stationary_ticks": 42})
|
||||||
|
assert_int(GameState.stationary_ticks).is_equal(42)
|
||||||
|
|
||||||
|
|
||||||
|
func test_stationary_ticks_server_value_overrides_client_accumulation() -> void:
|
||||||
|
## D-020: Server value takes priority — client must not accumulate on top of it.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
"stationary_ticks": 5,
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
GameState.apply_snapshot(snapshot) # same position, but server sends 5 again
|
||||||
|
assert_int(GameState.stationary_ticks).is_equal(5) # server value, not 6
|
||||||
|
|
||||||
|
|
||||||
|
func test_stationary_ticks_missing_field_degrades_gracefully() -> void:
|
||||||
|
## D-020 fallback: when server omits stationary_ticks, no crash, defaults to 0.
|
||||||
|
GameState.stationary_ticks = 0
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||||
|
# No crash; field retains a valid integer value.
|
||||||
|
assert_int(GameState.stationary_ticks).is_greater_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Stationary ticks: deprecated client-side fallback (D-071) -----------
|
||||||
|
|
||||||
|
func test_stationary_ticks_fallback_increments_when_position_unchanged() -> void:
|
||||||
|
## Deprecated fallback: client accumulates when server omits the field.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot) # first call: position changes from sentinel
|
||||||
|
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
|
||||||
|
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_stationary_ticks_fallback_resets_on_movement() -> void:
|
||||||
|
## Deprecated fallback: client resets on movement when server omits the field.
|
||||||
|
var s1 := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Zone ID: server-authoritative (D-020/D-073) -------------------------
|
||||||
|
|
||||||
|
func test_zone_id_from_server_snapshot() -> void:
|
||||||
|
## D-020: When server sends top-level zone_id, client reads it directly.
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": [], "zone_id": "zone_alpha"})
|
||||||
|
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
|
||||||
|
func test_zone_id_server_value_overrides_tile_derivation() -> void:
|
||||||
|
## D-020: Server top-level zone_id takes priority over tile-derived zone_id.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||||
|
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"}],
|
||||||
|
"zone_id": "zone_from_server",
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_that(GameState.current_zone_id).is_equal("zone_from_server")
|
||||||
|
|
||||||
|
|
||||||
|
func test_zone_id_missing_field_degrades_gracefully() -> void:
|
||||||
|
## D-020 fallback: when server omits zone_id and no tiles match, defaults to "".
|
||||||
|
GameState.current_zone_id = ""
|
||||||
|
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||||
|
assert_that(GameState.current_zone_id).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- insert_active (OQ-07, #522) -----------------------------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_insert_active_false() -> void:
|
||||||
|
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,164 @@
|
|||||||
|
## Sprint 20 #557: GameState.apply_snapshot() refactor tests.
|
||||||
|
##
|
||||||
|
## Verifies D-020 compliance: apply_snapshot() reads server-authoritative values
|
||||||
|
## for stationary_ticks and zone_id directly from the snapshot when present,
|
||||||
|
## and degrades gracefully when the server has not yet added these fields.
|
||||||
|
##
|
||||||
|
## Does NOT replace test_game_state.gd or test_snapshot_zone_id.gd — those cover
|
||||||
|
## existing behaviour. This file covers the new code paths added in Sprint 20.
|
||||||
|
##
|
||||||
|
## D-030: fixture-based, server-free, no subprocess required.
|
||||||
|
class_name TestGameStateSprint20
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func before_each() -> void:
|
||||||
|
GameState.stationary_ticks = 0
|
||||||
|
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||||
|
GameState.current_zone_id = ""
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.visibility_sectors = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- stationary_ticks: server-authoritative path (D-020) ----------------------
|
||||||
|
|
||||||
|
func test_stationary_ticks_reads_server_value_when_present() -> void:
|
||||||
|
# When snapshot includes stationary_ticks, apply_snapshot() must use the
|
||||||
|
# server value directly without client-side accumulation (D-020).
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 5,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"stationary_ticks": 42,
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||||
|
"apply_snapshot() must read stationary_ticks=42 from snapshot (D-020)"
|
||||||
|
).is_equal(42)
|
||||||
|
|
||||||
|
|
||||||
|
func test_stationary_ticks_server_value_does_not_accumulate() -> void:
|
||||||
|
# Server-sent value must be assigned directly — NOT added to existing value.
|
||||||
|
# Two calls with stationary_ticks=10 must yield 10, not 20.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"stationary_ticks": 10,
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||||
|
"Server value must be assigned directly, not accumulated (D-020)"
|
||||||
|
).is_equal(10)
|
||||||
|
|
||||||
|
|
||||||
|
func test_stationary_ticks_server_can_reset_to_zero() -> void:
|
||||||
|
# Server sends 0 when player moves — client must accept this reset.
|
||||||
|
GameState.stationary_ticks = 50
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 2,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"stationary_ticks": 0,
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||||
|
"Server reset to 0 must override client-held value"
|
||||||
|
).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- stationary_ticks: DEPRECATED fallback (graceful degradation) --------------
|
||||||
|
|
||||||
|
func test_stationary_ticks_fallback_when_field_absent() -> void:
|
||||||
|
# When snapshot has no stationary_ticks, client-side accumulation must still
|
||||||
|
# run (backward compat until server ships field). No crash.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot) # position changes from ZERO → resets to 0
|
||||||
|
GameState.apply_snapshot(snapshot) # position unchanged → increments
|
||||||
|
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||||
|
"Client-side fallback must increment stationary_ticks when field absent"
|
||||||
|
).is_greater(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_missing_stationary_ticks_no_crash() -> void:
|
||||||
|
# Snapshots lacking stationary_ticks must not crash apply_snapshot().
|
||||||
|
var snapshot := {"tick": 1, "entities": []}
|
||||||
|
# No assertion needed beyond confirming no exception is raised.
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_bool(true).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- zone_id: server-authoritative path (D-020/D-073) -------------------------
|
||||||
|
|
||||||
|
func test_current_zone_id_reads_server_value_when_present() -> void:
|
||||||
|
# When snapshot includes top-level zone_id, apply_snapshot() must use it
|
||||||
|
# directly without tile lookup (D-020).
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"zone_id": "zone_server_direct",
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_str(GameState.current_zone_id).override_failure_message(
|
||||||
|
"apply_snapshot() must read zone_id='zone_server_direct' from snapshot (D-020)"
|
||||||
|
).is_equal("zone_server_direct")
|
||||||
|
|
||||||
|
|
||||||
|
func test_current_zone_id_server_value_overrides_tile_data() -> void:
|
||||||
|
# When snapshot has both zone_id and visible_tiles with a different zone,
|
||||||
|
# the top-level zone_id field takes priority.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"zone_id": "zone_from_server",
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_str(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Top-level zone_id must override tile-derived zone when both present"
|
||||||
|
).is_equal("zone_from_server")
|
||||||
|
|
||||||
|
|
||||||
|
# -- zone_id: DEPRECATED fallback (graceful degradation) ----------------------
|
||||||
|
|
||||||
|
func test_current_zone_id_fallback_to_tile_lookup_when_absent() -> void:
|
||||||
|
# When snapshot lacks top-level zone_id, tile lookup fallback must run.
|
||||||
|
# Existing test_snapshot_zone_id.gd covers detailed scenarios; this is a
|
||||||
|
# smoke test confirming the fallback path still works after Sprint 20 refactor.
|
||||||
|
var snapshot := {
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_tile_fallback"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_str(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Tile lookup fallback must populate zone_id when top-level field absent"
|
||||||
|
).is_equal("zone_tile_fallback")
|
||||||
|
|
||||||
|
|
||||||
|
func test_apply_snapshot_missing_zone_id_no_crash() -> void:
|
||||||
|
# Snapshots lacking both zone_id and visible_tiles must not crash.
|
||||||
|
var snapshot := {"tick": 1, "entities": []}
|
||||||
|
GameState.apply_snapshot(snapshot)
|
||||||
|
assert_str(GameState.current_zone_id).is_equal("")
|
||||||
@@ -0,0 +1,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")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## SnapshotEventRouter unit tests (#559).
|
||||||
|
## Verifies callable-based dispatch: keyed handlers receive correct field values,
|
||||||
|
## always handlers run on every dispatch, absent fields don't trigger handlers.
|
||||||
|
##
|
||||||
|
## D-030: fixture-based, server-free, no subprocess required.
|
||||||
|
class_name TestSnapshotEventRouter
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# -- Keyed handlers -----------------------------------------------------------
|
||||||
|
|
||||||
|
func test_keyed_handler_called_when_field_present() -> void:
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
var received: Array = []
|
||||||
|
router.register("current_monologue", func(): received.append("monologue"))
|
||||||
|
router.dispatch({"current_monologue": {"text": "Test."}})
|
||||||
|
assert_that(received.size()).is_equal(1)
|
||||||
|
assert_that(received[0]).is_equal("monologue")
|
||||||
|
|
||||||
|
|
||||||
|
func test_keyed_handler_not_called_when_field_absent() -> void:
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
var received: Array = []
|
||||||
|
router.register("current_monologue", func(): received.append("monologue"))
|
||||||
|
router.dispatch({"tick": 1})
|
||||||
|
assert_that(received.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_multiple_keyed_handlers_same_field() -> void:
|
||||||
|
## Multiple handlers on the same key run in registration order.
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
var order: Array = []
|
||||||
|
router.register("save_result", func(): order.append("first"))
|
||||||
|
router.register("save_result", func(): order.append("second"))
|
||||||
|
router.dispatch({"save_result": {"success": true}})
|
||||||
|
assert_that(order).is_equal(["first", "second"])
|
||||||
|
|
||||||
|
|
||||||
|
func test_keyed_handlers_multiple_fields() -> void:
|
||||||
|
## Each keyed handler fires only for its registered field.
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
var received: Array = []
|
||||||
|
router.register("current_monologue", func(): received.append("mono"))
|
||||||
|
router.register("current_dialogue", func(): received.append("dlg"))
|
||||||
|
router.dispatch({"current_monologue": {"text": "Hi."}})
|
||||||
|
assert_that(received).is_equal(["mono"])
|
||||||
|
received.clear()
|
||||||
|
router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}})
|
||||||
|
assert_bool(received.has("mono")).is_true()
|
||||||
|
assert_bool(received.has("dlg")).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Always handlers ----------------------------------------------------------
|
||||||
|
|
||||||
|
func test_always_handler_called_on_every_dispatch() -> void:
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
var count: Array[int] = [0]
|
||||||
|
router.register_always(func(): count[0] += 1)
|
||||||
|
router.dispatch({"tick": 1})
|
||||||
|
router.dispatch({"tick": 2})
|
||||||
|
router.dispatch({})
|
||||||
|
assert_int(count[0]).is_equal(3)
|
||||||
|
|
||||||
|
|
||||||
|
func test_always_handler_runs_before_keyed() -> void:
|
||||||
|
## Always handlers run before keyed handlers (dispatch order guarantee).
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
var order: Array = []
|
||||||
|
router.register("current_monologue", func(): order.append("keyed"))
|
||||||
|
router.register_always(func(): order.append("always"))
|
||||||
|
router.dispatch({"current_monologue": {"text": "Hi."}})
|
||||||
|
assert_that(order[0]).is_equal("always")
|
||||||
|
assert_that(order[1]).is_equal("keyed")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Empty dispatch ------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_empty_snapshot_no_crash() -> void:
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
router.register("foo", func(): pass)
|
||||||
|
router.register_always(func(): pass)
|
||||||
|
# Must not crash
|
||||||
|
router.dispatch({})
|
||||||
|
|
||||||
|
|
||||||
|
func test_no_handlers_no_crash() -> void:
|
||||||
|
var router := SnapshotEventRouter.new()
|
||||||
|
# Must not crash
|
||||||
|
router.dispatch({"tick": 1, "entities": []})
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
## Sprint 17 — Time display on insert HUD (#263)
|
||||||
|
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
|
||||||
|
##
|
||||||
|
## Spec refs:
|
||||||
|
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
||||||
|
## D-051 (diegetic insert display)
|
||||||
|
##
|
||||||
|
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
|
||||||
|
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
||||||
|
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
|
||||||
|
##
|
||||||
|
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
||||||
|
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
||||||
|
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
||||||
|
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
|
||||||
|
## reading Label.text via public node paths instead.
|
||||||
|
class_name TestTimeDisplaySprint17
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var _clock: Control = null
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.game_time = {}
|
||||||
|
var ClockScript = load("res://ui/time_display.gd")
|
||||||
|
_clock = Control.new()
|
||||||
|
_clock.set_script(ClockScript)
|
||||||
|
add_child(_clock)
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
if _clock and is_instance_valid(_clock):
|
||||||
|
_clock.queue_free()
|
||||||
|
_clock = null
|
||||||
|
GameState.game_time = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Constants.format_game_time() — pure logic, D-031
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_format_midnight() -> void:
|
||||||
|
assert_that(Constants.format_game_time(0)).is_equal("00:00")
|
||||||
|
|
||||||
|
func test_format_morning_start() -> void:
|
||||||
|
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
|
||||||
|
assert_that(Constants.format_game_time(360)).is_equal("06:00")
|
||||||
|
|
||||||
|
func test_format_noon() -> void:
|
||||||
|
assert_that(Constants.format_game_time(720)).is_equal("12:00")
|
||||||
|
|
||||||
|
func test_format_evening_start() -> void:
|
||||||
|
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
|
||||||
|
|
||||||
|
func test_format_end_of_day() -> void:
|
||||||
|
# Last valid minute — must not wrap or overflow
|
||||||
|
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
|
||||||
|
|
||||||
|
func test_format_pads_single_digit_hour() -> void:
|
||||||
|
# 30 min = 00:30
|
||||||
|
assert_that(Constants.format_game_time(30)).is_equal("00:30")
|
||||||
|
|
||||||
|
func test_format_pads_single_digit_minute() -> void:
|
||||||
|
# 121 min = 02:01
|
||||||
|
assert_that(Constants.format_game_time(121)).is_equal("02:01")
|
||||||
|
|
||||||
|
func test_format_half_past_hour() -> void:
|
||||||
|
assert_that(Constants.format_game_time(90)).is_equal("01:30")
|
||||||
|
|
||||||
|
func test_format_arbitrary_midday() -> void:
|
||||||
|
# 835 min = 13:55
|
||||||
|
assert_that(Constants.format_game_time(835)).is_equal("13:55")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# InsertClock initial state
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_insert_clock_initial_time_str_is_placeholder() -> void:
|
||||||
|
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
|
||||||
|
assert_that(_clock._time_str).is_equal("--:--")
|
||||||
|
|
||||||
|
func test_insert_clock_initial_phase_str_is_empty() -> void:
|
||||||
|
assert_that(_clock._phase_str).is_equal("")
|
||||||
|
|
||||||
|
func test_insert_clock_initial_day_str_is_empty() -> void:
|
||||||
|
assert_that(_clock._day_str).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# InsertClock.update_from_state() — reads GameState.game_time
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_update_from_state_formats_time_str() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("12:00")
|
||||||
|
|
||||||
|
func test_update_from_state_sets_phase_str() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._phase_str).is_equal("Evening")
|
||||||
|
|
||||||
|
func test_update_from_state_sets_day_str_one_indexed() -> void:
|
||||||
|
# Day 0 from server → "D1" display (1-indexed)
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._day_str).is_equal("D1")
|
||||||
|
|
||||||
|
func test_update_from_state_day_2() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._day_str).is_equal("D2")
|
||||||
|
|
||||||
|
func test_update_from_state_midnight() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("00:00")
|
||||||
|
|
||||||
|
func test_update_from_state_end_of_day() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("23:59")
|
||||||
|
|
||||||
|
func test_update_from_state_skips_empty_game_time() -> void:
|
||||||
|
# Empty game_time must not overwrite --:-- (guard in update_from_state)
|
||||||
|
GameState.game_time = {}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("--:--")
|
||||||
|
|
||||||
|
func test_update_from_state_deduplicates_same_tick() -> void:
|
||||||
|
# Calling twice with identical data must produce same result (signature cache)
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("06:00")
|
||||||
|
# Call again — result unchanged, no crash
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("06:00")
|
||||||
|
|
||||||
|
func test_update_from_state_updates_on_new_tick() -> void:
|
||||||
|
# time_of_day changes → signature changes → _time_str updates
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("01:00")
|
||||||
|
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("02:00")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_phase_colors_has_morning() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
|
||||||
|
|
||||||
|
func test_phase_colors_has_afternoon() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
|
||||||
|
|
||||||
|
func test_phase_colors_has_evening() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
|
||||||
|
|
||||||
|
func test_phase_colors_has_night() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
|
||||||
|
|
||||||
|
func test_phase_color_applied_after_update() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
|
||||||
|
|
||||||
|
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
|
||||||
|
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# GameState: game_time field parsing (confirms apply_snapshot wiring)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_game_time_populated_from_snapshot() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 5, "entities": [],
|
||||||
|
"game_time": {
|
||||||
|
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
|
||||||
|
|
||||||
|
func test_game_time_all_four_phases_store_correctly() -> void:
|
||||||
|
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
|
||||||
|
|
||||||
|
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
|
||||||
|
|
||||||
|
func test_game_time_zero_time_of_day_stored() -> void:
|
||||||
|
# time_of_day = 0 (midnight) must not be treated as falsy/missing
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# SimBridge test mode: game_time fields are valid
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_sim_bridge_snapshot_has_game_time() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
assert_that(snap.has("game_time")).is_true()
|
||||||
|
assert_that(snap.game_time is Dictionary).is_true()
|
||||||
|
|
||||||
|
func test_sim_bridge_game_time_has_required_fields() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
var gt: Dictionary = snap.game_time
|
||||||
|
assert_that(gt.has("day")).is_true()
|
||||||
|
assert_that(gt.has("time_of_day")).is_true()
|
||||||
|
assert_that(gt.has("day_phase")).is_true()
|
||||||
|
assert_that(gt.has("tick_rate")).is_true()
|
||||||
|
|
||||||
|
func test_sim_bridge_time_of_day_is_non_negative() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
|
||||||
|
|
||||||
|
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
|
||||||
|
# D-031: 1440 game-minutes per day, valid range 0..1439
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
|
||||||
|
|
||||||
|
func test_sim_bridge_day_phase_is_valid() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
var phase: String = snap.game_time.get("day_phase", "")
|
||||||
|
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Scene: InsertClock node at InsertOverlay/TimeDisplay
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
var instance = scene.instantiate()
|
||||||
|
auto_free(instance)
|
||||||
|
add_child(instance)
|
||||||
|
|
||||||
|
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||||
|
|
||||||
|
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
var instance = scene.instantiate()
|
||||||
|
auto_free(instance)
|
||||||
|
add_child(instance)
|
||||||
|
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||||
|
})
|
||||||
|
instance._process(0.016)
|
||||||
|
|
||||||
|
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
|
||||||
|
assert_that(clock).is_not_null()
|
||||||
|
assert_that(clock._time_str).is_equal("12:00")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Regression: debug_overlay still reads game_time correctly (#511)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_debug_overlay_reads_game_time_day_phase() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
|
||||||
|
|
||||||
|
func test_debug_overlay_reads_game_time_tick_rate() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
|
||||||
@@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_minimap_placeholder_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")
|
var scene := load("res://scenes/main.tscn")
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_hud_exists_in_ui_layer() -> void:
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
## YamlParser unit tests (#560).
|
||||||
|
## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values).
|
||||||
|
## Covers: maps, nested maps, arrays of dicts, type conversion, comments,
|
||||||
|
## quoted strings, empty input, edge cases.
|
||||||
|
##
|
||||||
|
## D-030: fixture-based, server-free, no subprocess required.
|
||||||
|
class_name TestYamlParser
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# -- parse(): basic key-value pairs -------------------------------------------
|
||||||
|
|
||||||
|
func test_parse_simple_key_value() -> void:
|
||||||
|
var result := YamlParser.parse("key: value")
|
||||||
|
assert_that(result["key"]).is_equal("value")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_quoted_string() -> void:
|
||||||
|
var result := YamlParser.parse('key: "hello world"')
|
||||||
|
assert_that(result["key"]).is_equal("hello world")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_empty_quoted_string() -> void:
|
||||||
|
var result := YamlParser.parse('key: ""')
|
||||||
|
assert_that(result["key"]).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_integer_value() -> void:
|
||||||
|
var result := YamlParser.parse("count: 42")
|
||||||
|
assert_that(result["count"]).is_equal(42)
|
||||||
|
assert_that(typeof(result["count"])).is_equal(TYPE_INT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_negative_integer() -> void:
|
||||||
|
var result := YamlParser.parse("offset: -3")
|
||||||
|
assert_that(result["offset"]).is_equal(-3)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_float_value() -> void:
|
||||||
|
var result := YamlParser.parse("radius: 2.5")
|
||||||
|
assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT)
|
||||||
|
assert_float(result["radius"]).is_equal_approx(2.5, 0.001)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_boolean_true() -> void:
|
||||||
|
var result := YamlParser.parse("enabled: true")
|
||||||
|
assert_that(result["enabled"]).is_equal(true)
|
||||||
|
assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_boolean_false() -> void:
|
||||||
|
var result := YamlParser.parse("enabled: false")
|
||||||
|
assert_that(result["enabled"]).is_equal(false)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_empty_input() -> void:
|
||||||
|
var result := YamlParser.parse("")
|
||||||
|
assert_that(result.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_only_comments() -> void:
|
||||||
|
var result := YamlParser.parse("# comment\n# another")
|
||||||
|
assert_that(result.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_inline_comment_stripped() -> void:
|
||||||
|
var result := YamlParser.parse("room_id: test # this is a comment")
|
||||||
|
assert_that(result["room_id"]).is_equal("test")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_quoted_value_with_hash() -> void:
|
||||||
|
## Quoted strings preserve literal # characters.
|
||||||
|
var result := YamlParser.parse('color: "#e0e8ff"')
|
||||||
|
assert_that(result["color"]).is_equal("#e0e8ff")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_value_with_colon() -> void:
|
||||||
|
var result := YamlParser.parse('time: "12:30"')
|
||||||
|
assert_that(result["time"]).is_equal("12:30")
|
||||||
|
|
||||||
|
|
||||||
|
# -- parse(): nested maps -----------------------------------------------------
|
||||||
|
|
||||||
|
func test_parse_nested_map() -> void:
|
||||||
|
var yaml := "section:\n key: value"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result.has("section")).is_true()
|
||||||
|
assert_that(result["section"] is Dictionary).is_true()
|
||||||
|
assert_that(result["section"]["key"]).is_equal("value")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_deeply_nested() -> void:
|
||||||
|
var yaml := "a:\n b:\n c: deep"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["a"]["b"]["c"]).is_equal("deep")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_multiple_sections() -> void:
|
||||||
|
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["hud"]["mode"]).is_equal("Mode")
|
||||||
|
assert_that(result["interaction"]["talk"]).is_equal("Talk")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_multiple_keys_per_section() -> void:
|
||||||
|
var yaml := "hud:\n a: 1\n b: 2\n c: 3"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["hud"]["a"]).is_equal(1)
|
||||||
|
assert_that(result["hud"]["b"]).is_equal(2)
|
||||||
|
assert_that(result["hud"]["c"]).is_equal(3)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_sibling_subsections() -> void:
|
||||||
|
var yaml := "states:\n a:\n x: 1\n b:\n x: 2"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["states"]["a"]["x"]).is_equal(1)
|
||||||
|
assert_that(result["states"]["b"]["x"]).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_section_with_trailing_comment() -> void:
|
||||||
|
## "section: # comment" should be treated as a section header.
|
||||||
|
var yaml := "section: # comment\n key: value"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["section"]["key"]).is_equal("value")
|
||||||
|
|
||||||
|
|
||||||
|
# -- parse(): arrays of dicts -------------------------------------------------
|
||||||
|
|
||||||
|
func test_parse_single_array_item() -> void:
|
||||||
|
var yaml := "conditions:\n - id: test-1\n type: near\n x: 10"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result.has("conditions")).is_true()
|
||||||
|
assert_that(result["conditions"] is Array).is_true()
|
||||||
|
assert_that(result["conditions"].size()).is_equal(1)
|
||||||
|
assert_that(result["conditions"][0]["id"]).is_equal("test-1")
|
||||||
|
assert_that(result["conditions"][0]["type"]).is_equal("near")
|
||||||
|
assert_that(result["conditions"][0]["x"]).is_equal(10)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_multiple_array_items() -> void:
|
||||||
|
var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["conditions"].size()).is_equal(2)
|
||||||
|
assert_that(result["conditions"][0]["id"]).is_equal("a")
|
||||||
|
assert_that(result["conditions"][1]["id"]).is_equal("b")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_array_items_with_blank_lines() -> void:
|
||||||
|
var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["conditions"].size()).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_top_level_plus_array() -> void:
|
||||||
|
## Checklist format: top-level key-value pairs followed by a conditions array.
|
||||||
|
var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["room_id"]).is_equal("warehouse")
|
||||||
|
assert_that(result["conditions"].size()).is_equal(1)
|
||||||
|
assert_that(result["conditions"][0]["id"]).is_equal("c1")
|
||||||
|
|
||||||
|
|
||||||
|
func test_parse_array_typed_values() -> void:
|
||||||
|
var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
var item: Dictionary = result["items"][0]
|
||||||
|
assert_that(item["x"]).is_equal(42)
|
||||||
|
assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT)
|
||||||
|
assert_that(item["active"]).is_equal(true)
|
||||||
|
|
||||||
|
|
||||||
|
# -- parse_flat(): dotted keys -------------------------------------------------
|
||||||
|
|
||||||
|
func test_flat_simple() -> void:
|
||||||
|
var yaml := "section:\n key: value"
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result.has("section.key")).is_true()
|
||||||
|
assert_that(result["section.key"]).is_equal("value")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_deeply_nested() -> void:
|
||||||
|
var yaml := "a:\n b:\n c: deep"
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result["a.b.c"]).is_equal("deep")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_multiple_sections() -> void:
|
||||||
|
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result["hud.mode"]).is_equal("Mode")
|
||||||
|
assert_that(result["interaction.talk"]).is_equal("Talk")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_values_are_strings() -> void:
|
||||||
|
## parse_flat returns all values as strings, unlike parse() which returns typed.
|
||||||
|
var yaml := "section:\n count: 42\n rate: 0.9\n active: true"
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result["section.count"]).is_equal("42")
|
||||||
|
assert_that(result["section.rate"]).is_equal("0.9")
|
||||||
|
assert_that(result["section.active"]).is_equal("true")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_quoted_value() -> void:
|
||||||
|
var yaml := 'section:\n key: "hello world"'
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result["section.key"]).is_equal("hello world")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_inline_comment() -> void:
|
||||||
|
var yaml := "hud:\n mode: Standard # default"
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result["hud.mode"]).is_equal("Standard")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_empty_quoted_value() -> void:
|
||||||
|
var yaml := 'hud:\n prefix: "" # No prefix'
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result.has("hud.prefix")).is_true()
|
||||||
|
assert_that(result["hud.prefix"]).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_skips_arrays() -> void:
|
||||||
|
## Arrays have no dotted-key representation — they are skipped in flat output.
|
||||||
|
var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5"
|
||||||
|
var result := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(result.has("room_id")).is_true()
|
||||||
|
# No dotted keys for array contents
|
||||||
|
assert_that(result.has("conditions")).is_false()
|
||||||
|
assert_that(result.has("conditions.0")).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_flat_empty_input() -> void:
|
||||||
|
var result := YamlParser.parse_flat("")
|
||||||
|
assert_that(result.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Dialogue theme format (regression) ----------------------------------------
|
||||||
|
|
||||||
|
func test_dialogue_theme_format() -> void:
|
||||||
|
## dialogue-theme.yaml has top-level values and one nested map (npc_colors).
|
||||||
|
var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9"
|
||||||
|
var flat := YamlParser.parse_flat(yaml)
|
||||||
|
assert_that(flat["player_color"]).is_equal("#e0e8ff")
|
||||||
|
assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb")
|
||||||
|
assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6")
|
||||||
|
assert_that(flat["passive_opacity"]).is_equal("0.9")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Checklist format (regression) ---------------------------------------------
|
||||||
|
|
||||||
|
func test_checklist_format() -> void:
|
||||||
|
## checklist.yaml: top-level kv + conditions array with typed values.
|
||||||
|
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||||
|
var result := YamlParser.parse(yaml)
|
||||||
|
assert_that(result["room_id"]).is_equal("inventory_warehouse")
|
||||||
|
var cond: Dictionary = result["conditions"][0]
|
||||||
|
assert_that(cond["id"]).is_equal("test-1")
|
||||||
|
assert_that(cond["condition_type"]).is_equal("player_near")
|
||||||
|
assert_that(cond["x"]).is_equal(10)
|
||||||
|
assert_that(cond["y"]).is_equal(20)
|
||||||
|
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
|
||||||
@@ -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]
|
||||||
+70
-36
@@ -15,6 +15,11 @@ signal dialogue_dismissed # Walk-away or conversation end
|
|||||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||||
signal unpause_requested # D-061: auto-unpause
|
signal unpause_requested # D-061: auto-unpause
|
||||||
|
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
|
||||||
|
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
|
||||||
|
signal dialogue_state_changed(active: bool)
|
||||||
|
signal audio_dip_requested(profile: String)
|
||||||
|
signal audio_dip_cleared
|
||||||
|
|
||||||
@onready var panel: PanelContainer = $PanelContainer
|
@onready var panel: PanelContainer = $PanelContainer
|
||||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||||
@@ -286,10 +291,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
|||||||
# Show panel
|
# Show panel
|
||||||
_ensure_visible()
|
_ensure_visible()
|
||||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
GameState.dialogue_active = true # D-064: block movement while in conversation
|
dialogue_state_changed.emit(true) # D-064: coordinator blocks movement
|
||||||
|
|
||||||
# D-069: Dialogue dip
|
# D-069: Dialogue dip — coordinator routes to AudioManager
|
||||||
AudioManager.apply_dip("dialogue")
|
audio_dip_requested.emit("dialogue")
|
||||||
|
|
||||||
# D-061: auto-pause — signal to main.gd for input recording (#507)
|
# D-061: auto-pause — signal to main.gd for input recording (#507)
|
||||||
pause_requested.emit()
|
pause_requested.emit()
|
||||||
@@ -311,14 +316,14 @@ func _end_player_conversation() -> void:
|
|||||||
entry.timestamp_msec = now
|
entry.timestamp_msec = now
|
||||||
_log_dirty = true
|
_log_dirty = true
|
||||||
|
|
||||||
# D-069: Clear dialogue/confrontation dip
|
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
|
||||||
AudioManager.clear_dip()
|
audio_dip_cleared.emit()
|
||||||
|
|
||||||
# D-061: unpause — signal to main.gd for input recording (#507)
|
# D-061: unpause — signal to main.gd for input recording (#507)
|
||||||
unpause_requested.emit()
|
unpause_requested.emit()
|
||||||
|
|
||||||
# D-064: unblock movement immediately — log entries stay visible but don't block input.
|
# D-064: unblock movement immediately — coordinator handles GameState update.
|
||||||
GameState.dialogue_active = false
|
dialogue_state_changed.emit(false)
|
||||||
|
|
||||||
# If no entries remain, hide the panel with fade.
|
# If no entries remain, hide the panel with fade.
|
||||||
if _log_entries.is_empty():
|
if _log_entries.is_empty():
|
||||||
@@ -331,11 +336,11 @@ func hide_dialogue() -> void:
|
|||||||
_end_player_conversation()
|
_end_player_conversation()
|
||||||
return # _end_player_conversation may call hide_dialogue if log is empty
|
return # _end_player_conversation may call hide_dialogue if log is empty
|
||||||
|
|
||||||
GameState.dialogue_active = false
|
dialogue_state_changed.emit(false) # D-020: coordinator handles GameState update
|
||||||
|
|
||||||
|
|
||||||
func is_dialogue_active() -> bool:
|
func is_dialogue_active() -> bool:
|
||||||
return _in_player_conversation or GameState.dialogue_active
|
return _in_player_conversation
|
||||||
|
|
||||||
|
|
||||||
func has_active_entries() -> bool:
|
func has_active_entries() -> bool:
|
||||||
@@ -354,26 +359,45 @@ func _show_options(options: Array) -> void:
|
|||||||
var raw_text: String = opt.get("text", "")
|
var raw_text: String = opt.get("text", "")
|
||||||
var is_confrontation: bool = opt.get("confrontation", false)
|
var is_confrontation: bool = opt.get("confrontation", false)
|
||||||
|
|
||||||
var label := Label.new()
|
# D-063: Confrontation options render italic — first-person voice, weighted differently.
|
||||||
label.add_theme_font_size_override("font_size", 14)
|
# Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard.
|
||||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
var ctrl: Control
|
||||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
if is_confrontation:
|
||||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
var rtl := RichTextLabel.new()
|
||||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
rtl.bbcode_enabled = true
|
||||||
|
rtl.fit_content = true
|
||||||
var numbered_text := "%d. %s" % [i + 1, raw_text]
|
rtl.scroll_active = false
|
||||||
label.text = numbered_text
|
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
|
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:
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
_on_option_pressed(idx)
|
_on_option_pressed(idx)
|
||||||
)
|
)
|
||||||
label.mouse_entered.connect(_make_hover_on(label))
|
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
|
||||||
label.mouse_exited.connect(_make_hover_off(label))
|
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
|
||||||
|
|
||||||
options_container.add_child(label)
|
options_container.add_child(ctrl)
|
||||||
_option_controls.append(label)
|
_option_controls.append(ctrl)
|
||||||
_option_response_ids.append(opt.get("response_id", ""))
|
_option_response_ids.append(opt.get("response_id", ""))
|
||||||
_option_texts.append(raw_text)
|
_option_texts.append(raw_text)
|
||||||
_option_is_confrontation.append(is_confrontation)
|
_option_is_confrontation.append(is_confrontation)
|
||||||
@@ -409,12 +433,12 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
|
|||||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||||
|
|
||||||
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
||||||
AudioManager.apply_dip("confrontation")
|
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
|
||||||
|
|
||||||
_beat_tween = create_tween()
|
_beat_tween = create_tween()
|
||||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||||
_beat_tween.tween_callback(func():
|
_beat_tween.tween_callback(func():
|
||||||
AudioManager.clear_dip()
|
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||||
option_selected.emit(response_id, text)
|
option_selected.emit(response_id, text)
|
||||||
_end_player_conversation()
|
_end_player_conversation()
|
||||||
)
|
)
|
||||||
@@ -424,7 +448,7 @@ func _cancel_beat() -> void:
|
|||||||
if _beat_tween and _beat_tween.is_valid():
|
if _beat_tween and _beat_tween.is_valid():
|
||||||
_beat_tween.kill()
|
_beat_tween.kill()
|
||||||
_beat_tween = null
|
_beat_tween = null
|
||||||
AudioManager.clear_dip()
|
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||||
|
|
||||||
|
|
||||||
# -- Log rendering --
|
# -- Log rendering --
|
||||||
@@ -519,7 +543,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
|||||||
|
|
||||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||||
static func _escape_bbcode(text: String) -> String:
|
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.
|
## Get a stable color for a character name, with contrast floor enforcement.
|
||||||
@@ -575,19 +599,22 @@ func _expire_entries() -> void:
|
|||||||
var removed := false
|
var removed := false
|
||||||
var has_fading := false
|
var has_fading := false
|
||||||
|
|
||||||
# Remove expired non-pinned entries from the front (oldest first)
|
# Remove expired non-pinned entries (oldest first, skipping pinned)
|
||||||
while _log_entries.size() > 0:
|
var i := 0
|
||||||
var entry: Dictionary = _log_entries[0]
|
while i < _log_entries.size():
|
||||||
|
var entry: Dictionary = _log_entries[i]
|
||||||
if entry.pinned:
|
if entry.pinned:
|
||||||
break # Pinned entries never expire
|
i += 1
|
||||||
|
continue # Pinned entries never expire — keep scanning
|
||||||
var age: int = now - entry.timestamp_msec
|
var age: int = now - entry.timestamp_msec
|
||||||
if age < total_lifetime_msec:
|
if age < total_lifetime_msec:
|
||||||
# Check if this entry is in the fading phase
|
# Check if this entry is in the fading phase
|
||||||
if age > int(_entry_lifetime * 1000.0):
|
if age > int(_entry_lifetime * 1000.0):
|
||||||
has_fading = true
|
has_fading = true
|
||||||
break
|
break
|
||||||
_log_entries.remove_at(0)
|
_log_entries.remove_at(i)
|
||||||
removed = true
|
removed = true
|
||||||
|
# Don't increment i — element at i is now the next entry
|
||||||
|
|
||||||
# Check remaining entries for fading state
|
# Check remaining entries for fading state
|
||||||
if not has_fading:
|
if not has_fading:
|
||||||
@@ -623,11 +650,18 @@ func _clear_options() -> void:
|
|||||||
|
|
||||||
|
|
||||||
# Hover callbacks
|
# Hover callbacks
|
||||||
static func _make_hover_on(label: Control) -> Callable:
|
static func _make_hover_on(ctrl: Control) -> Callable:
|
||||||
return func():
|
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():
|
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)
|
||||||
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
|||||||
var _selected_index: int = 0
|
var _selected_index: int = 0
|
||||||
var _active_tween: Tween = null
|
var _active_tween: Tween = null
|
||||||
var _verb_labels: Array[Label] = []
|
var _verb_labels: Array[Label] = []
|
||||||
|
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
||||||
|
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||||
|
|
||||||
@onready var _vbox: VBoxContainer = $VBox
|
@onready var _vbox: VBoxContainer = $VBox
|
||||||
|
|
||||||
@@ -39,10 +41,14 @@ func _ready() -> void:
|
|||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
|
||||||
|
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
if _showing:
|
if _showing:
|
||||||
_update_screen_position()
|
_update_screen_position()
|
||||||
queue_redraw()
|
if position != _last_screen_pos:
|
||||||
|
_last_screen_pos = position
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
@@ -52,6 +58,9 @@ func _draw() -> void:
|
|||||||
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
||||||
draw_rect(bg_rect, INSERT_BG)
|
draw_rect(bg_rect, INSERT_BG)
|
||||||
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
||||||
|
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
|
||||||
|
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
|
||||||
|
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
|
||||||
|
|
||||||
|
|
||||||
func update_from_state() -> void:
|
func update_from_state() -> void:
|
||||||
@@ -88,6 +97,7 @@ func update_from_state() -> void:
|
|||||||
_verb_items = sorted
|
_verb_items = sorted
|
||||||
_selected_index = 0
|
_selected_index = 0
|
||||||
_cache_entity_position()
|
_cache_entity_position()
|
||||||
|
_cache_entity_relationship()
|
||||||
_rebuild_labels()
|
_rebuild_labels()
|
||||||
_show()
|
_show()
|
||||||
|
|
||||||
@@ -127,6 +137,17 @@ func _cache_entity_position() -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
## #537: Cache relationship color for the target entity (D-033 palette).
|
||||||
|
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
|
||||||
|
func _cache_entity_relationship() -> void:
|
||||||
|
for entity in GameState.visible_entities:
|
||||||
|
if entity.get("entity_id") == _current_target_id:
|
||||||
|
var rel: String = str(entity.get("relationship", "Unknown"))
|
||||||
|
_relationship_color = Constants.color_for_relationship(rel)
|
||||||
|
return
|
||||||
|
_relationship_color = Constants.IMPLANT_TEXT_DIM
|
||||||
|
|
||||||
|
|
||||||
## Convert entity world position to screen coords and reposition this Control.
|
## Convert entity world position to screen coords and reposition this Control.
|
||||||
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
||||||
func _update_screen_position() -> void:
|
func _update_screen_position() -> void:
|
||||||
@@ -187,6 +208,10 @@ func get_visible_verb_count() -> int:
|
|||||||
return _verb_items.size()
|
return _verb_items.size()
|
||||||
|
|
||||||
|
|
||||||
|
func hide_list() -> void:
|
||||||
|
_hide()
|
||||||
|
|
||||||
|
|
||||||
func is_showing() -> bool:
|
func is_showing() -> bool:
|
||||||
return _showing
|
return _showing
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
extends Control
|
||||||
|
|
||||||
# Minimap — small viewport showing local area
|
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
|
||||||
# Placeholder implementation for now
|
##
|
||||||
|
## 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:
|
func _ready() -> void:
|
||||||
print("Minimap: Initialized")
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
set_process(true)
|
||||||
|
|
||||||
# Update minimap view (stub)
|
func _process(_delta: float) -> void:
|
||||||
func update_minimap(player_pos: Vector2, entities: Array) -> void:
|
if _insert_active:
|
||||||
# TODO: Render minimap view
|
queue_redraw()
|
||||||
# This will show a top-down view of the local area
|
|
||||||
pass
|
## 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"]
|
[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
|
layout_mode = 3
|
||||||
anchors_preset = 1
|
anchors_preset = 1
|
||||||
anchor_left = 1.0
|
anchor_left = 1.0
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
offset_left = -200.0
|
offset_left = -176.0
|
||||||
offset_bottom = 200.0
|
offset_top = 16.0
|
||||||
|
offset_right = -16.0
|
||||||
|
offset_bottom = 176.0
|
||||||
grow_horizontal = 0
|
grow_horizontal = 0
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
script = ExtResource("1_minimap")
|
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_STANDARD: Color = Color("#c8d0e0")
|
||||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
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
|
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||||
|
|
||||||
@@ -62,7 +64,10 @@ func _process(delta: float) -> void:
|
|||||||
var now := float(Time.get_ticks_msec())
|
var now := float(Time.get_ticks_msec())
|
||||||
if now >= _next_fade_in_msec:
|
if now >= _next_fade_in_msec:
|
||||||
var next: Dictionary = _queue.pop_front()
|
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.
|
# Display a monologue line.
|
||||||
@@ -71,6 +76,26 @@ func _process(delta: float) -> void:
|
|||||||
# Empty text is silently ignored — no slot created, no queue entry.
|
# Empty text is silently ignored — no slot created, no queue entry.
|
||||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||||
# decoupled from the autoload (D-020 renderer contract).
|
# 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:
|
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||||
if text.is_empty():
|
if text.is_empty():
|
||||||
return
|
return
|
||||||
@@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
|
|||||||
# Internal
|
# 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:
|
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)
|
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||||
_vbox.add_child(line_node)
|
_vbox.add_child(line_node)
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ func _build_ui() -> void:
|
|||||||
close_btn.pressed.connect(close)
|
close_btn.pressed.connect(close)
|
||||||
_container.add_child(close_btn)
|
_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:
|
func _destroy_ui() -> void:
|
||||||
if _container:
|
if _container:
|
||||||
@@ -153,6 +161,11 @@ func _draw() -> void:
|
|||||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
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:
|
static func _format_db(db: float) -> String:
|
||||||
if db <= -40.0:
|
if db <= -40.0:
|
||||||
return "mute"
|
return "mute"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
|
||||||
|
## Shows station local time (HH:MM), day phase, and day number.
|
||||||
|
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
|
||||||
|
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
|
||||||
|
##
|
||||||
|
## Placeholder layout — position and style will be refined when #314 wireframe lands.
|
||||||
|
|
||||||
|
const FONT_SIZE_TIME: int = 15
|
||||||
|
const FONT_SIZE_META: int = 10
|
||||||
|
const PADDING := Vector2(10, 7)
|
||||||
|
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
|
||||||
|
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
|
||||||
|
|
||||||
|
# Day phase colors — station lighting cycle (D-031)
|
||||||
|
const PHASE_COLORS := {
|
||||||
|
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
|
||||||
|
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
|
||||||
|
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
|
||||||
|
"Night": Color("#4a7080"), # dark teal — station nightwatch
|
||||||
|
}
|
||||||
|
|
||||||
|
var _time_str: String = "--:--"
|
||||||
|
var _phase_str: String = ""
|
||||||
|
var _day_str: String = ""
|
||||||
|
var _day_text: String = ""
|
||||||
|
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||||
|
var _last_signature: String = ""
|
||||||
|
var _has_data: bool = false
|
||||||
|
|
||||||
|
# Cached geometry — recomputed in update_from_state(), used in _draw()
|
||||||
|
var _time_size: Vector2 = Vector2.ZERO
|
||||||
|
var _phase_size: Vector2 = Vector2.ZERO
|
||||||
|
var _day_size: Vector2 = Vector2.ZERO
|
||||||
|
var _meta_h: float = 0.0
|
||||||
|
var _box_w: float = 0.0
|
||||||
|
var _box_h: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
|
||||||
|
func update_from_state() -> void:
|
||||||
|
var gt: Dictionary = GameState.game_time
|
||||||
|
if gt.is_empty():
|
||||||
|
return
|
||||||
|
var tod: int = int(gt.get("time_of_day", 0))
|
||||||
|
var day: int = int(gt.get("day", 0))
|
||||||
|
var phase: String = str(gt.get("day_phase", ""))
|
||||||
|
var sig: String = "%d:%d:%s" % [tod, day, phase]
|
||||||
|
if sig == _last_signature:
|
||||||
|
return
|
||||||
|
_last_signature = sig
|
||||||
|
_time_str = Constants.format_game_time(tod)
|
||||||
|
_phase_str = phase
|
||||||
|
_day_str = "D%d" % (day + 1)
|
||||||
|
_day_text = " " + _day_str
|
||||||
|
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
|
||||||
|
_has_data = true
|
||||||
|
_cache_geometry()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _cache_geometry() -> void:
|
||||||
|
var font := ThemeDB.fallback_font
|
||||||
|
_time_size = font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
|
||||||
|
_phase_size = font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||||
|
_day_size = font.get_string_size(_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
|
||||||
|
_meta_h = font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
|
||||||
|
var meta_w := _phase_size.x + _day_size.x
|
||||||
|
var content_w := max(_time_size.x, meta_w)
|
||||||
|
_box_w = content_w + PADDING.x * 2
|
||||||
|
_box_h = PADDING.y * 2 + _time_size.y + 3 + _meta_h
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
if not _has_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Background
|
||||||
|
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BG_COLOR)
|
||||||
|
draw_rect(Rect2(Vector2.ZERO, Vector2(_box_w, _box_h)), BORDER_COLOR, false, 1.0)
|
||||||
|
|
||||||
|
var font := ThemeDB.fallback_font
|
||||||
|
|
||||||
|
# HH:MM (primary, full brightness)
|
||||||
|
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
|
||||||
|
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
|
||||||
|
|
||||||
|
# Phase + day number (secondary, dimmed + phase-tinted)
|
||||||
|
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
|
||||||
|
draw_string(font, Vector2(PADDING.x, meta_y),
|
||||||
|
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
|
||||||
|
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
|
||||||
|
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
|
||||||
|
|
||||||
|
; #263: Time display — top-left placeholder per D-013/D-051.
|
||||||
|
; Position and size will be refined when #314 wireframe lands.
|
||||||
|
; NOTE: draw-based content manages its own layout; Control rect is a
|
||||||
|
; minimum bounding box, not a clip rect. Increase if content grows.
|
||||||
|
[node name="TimeDisplay" type="Control"]
|
||||||
|
anchors_preset = 0
|
||||||
|
anchor_left = 0.0
|
||||||
|
anchor_top = 0.0
|
||||||
|
anchor_right = 0.0
|
||||||
|
anchor_bottom = 0.0
|
||||||
|
offset_left = 16.0
|
||||||
|
offset_top = 16.0
|
||||||
|
offset_right = 200.0
|
||||||
|
offset_bottom = 70.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
mouse_filter = 2
|
||||||
|
script = ExtResource("1_tdisplay")
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
character: detective
|
||||||
|
location: contradiction
|
||||||
|
lines:
|
||||||
|
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc
|
||||||
|
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
|
||||||
|
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
|
||||||
|
#
|
||||||
|
# Trigger: contradiction_detected fires when the detective observes Kael in Corridor B-7
|
||||||
|
# while the KG holds Sera's claim that Kael was at dock intake during second shift.
|
||||||
|
# Event payload: source_display_name = "Sera Venn", subject_display_name = "Kael Davan"
|
||||||
|
#
|
||||||
|
# FRIEND-pattern NPCs (Sera, Kael) always use hand-authored lines — generic template fallback
|
||||||
|
# does not fire for this arc.
|
||||||
|
#
|
||||||
|
# Authoring principle: cognitive dissonance, not accusation. The detective doesn't know
|
||||||
|
# who or what is wrong. Uncertainty first, suspicion second, accusation never.
|
||||||
|
#
|
||||||
|
# Phase 2 lines: trust established but not deep — the blindsiding.
|
||||||
|
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Sera Venn)
|
||||||
|
# Phase 3 lines: additional context accumulated — Sera's avoidance of Torek observed 3x.
|
||||||
|
# Prerequisite: Phase 2 + Sera's avoidance pattern has fired (three departures logged).
|
||||||
|
#
|
||||||
|
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
|
||||||
|
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
|
||||||
|
# fires once per KG state change), not by a cooldown value.
|
||||||
|
#
|
||||||
|
# Note: contradiction_detected is a new trigger type — requires server-side implementation
|
||||||
|
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
|
||||||
|
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_001
|
||||||
|
text: "Sera said Kael was at the dock intake during second shift. I'm looking at him in corridor B-7 right now."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 10
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
|
||||||
|
notes: >
|
||||||
|
Paula's canonical primary line — preserved verbatim. Flat factual statement: source named,
|
||||||
|
subject named, specific location claim vs. direct observation. The 'I'm' contraction is
|
||||||
|
natural in a surprise moment even for the analytical detective — the observation breaks
|
||||||
|
through the professional register. No interpretation, no accusation.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_002
|
||||||
|
text: "Venn said Kael was at dock intake through second shift. Kael is in B-7. Those aren't compatible."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 9
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical]
|
||||||
|
notes: >
|
||||||
|
Alternate primary — fully analytical register: surname-initial for both (institutional
|
||||||
|
reflex), no contractions, plain declarative close. 'Those aren't compatible' is the
|
||||||
|
detective's version of stating contradiction — clinical, process-language. Fires as
|
||||||
|
alternate to con_001 for variety on replay.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_003
|
||||||
|
text: "She placed Kael at the dock. He's not at the dock."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 9
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-2]
|
||||||
|
notes: >
|
||||||
|
Stripped-down alternate. 'She placed' is the informal of 'Venn stated' — personal register
|
||||||
|
triggered by Sera being THE FRIEND. 'He's not at the dock' — the barest possible statement
|
||||||
|
of contradiction. The brevity is the weight: six words and the investigation changes shape.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# PHASE 2 — Secondary beat (fires after short delay, cognitive processing)
|
||||||
|
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_004
|
||||||
|
text: "One of them is wrong. Sera, or what I'm seeing. Or I'm missing something I don't have yet."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, alone]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [anxious, focused]
|
||||||
|
priority: 7
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-2, cognitive-dissonance, secondary-beat]
|
||||||
|
notes: >
|
||||||
|
Paula's canonical secondary beat — fires 3-5 seconds after the primary beat.
|
||||||
|
Epistemic neutrality held across three options: Sera is wrong, observation is wrong, or
|
||||||
|
context is missing. No option is dismissed. 'I'm missing something I don't have yet' is
|
||||||
|
the key line — it holds accusation off even against evidence. The detective files it open.
|
||||||
|
Lower priority (7) ensures primary lines fire first; server team should implement
|
||||||
|
delay_after_trigger_ticks for proper sequencing.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_005
|
||||||
|
text: "File it. Don't close it. Either Venn's information was wrong when she gave it, or it was wrong on purpose. That's a different question."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, alone]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-2, analytical, cognitive-dissonance, secondary-beat]
|
||||||
|
notes: >
|
||||||
|
Alternate secondary beat. 'File it, don't close it' is the detective's internal procedure
|
||||||
|
for unresolved data. The critical distinction: wrong information vs. deliberately wrong
|
||||||
|
information — the detective separates these without assuming either. 'That's a different
|
||||||
|
question' closes the processing loop, marking it unresolved, not dismissed.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# PHASE 3 — With accumulated context (Sera's avoidance pattern observed)
|
||||||
|
# Requires: three Sera departures correlated with Torek Lintar arrivals observed.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_006
|
||||||
|
text: "Sera told me Kael doesn't make mistakes. He's not where she said he'd be. And she's been avoiding Lintar for three weeks."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 10
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:torek-lintar"
|
||||||
|
relationship: PersonOfInterest
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-3, pattern-recognition]
|
||||||
|
notes: >
|
||||||
|
Paula's canonical Phase 3 line — preserved verbatim. Three facts in sequence, no explicit
|
||||||
|
connection drawn between them: Sera's testimony about Kael ('doesn't make mistakes'),
|
||||||
|
Kael's actual location, Sera's avoidance behavior. The detective is connecting dots but
|
||||||
|
not announcing the conclusion. The emotional weight is in 'Sera told me' — personal register
|
||||||
|
for the friend relationship — vs. the clinical observation about Lintar.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_007
|
||||||
|
text: "Two facts: Venn placed Kael at dock intake, second shift. Kael is in B-7. One new observation: Venn leaves every time Lintar enters the bar. Worth seeing if those connect."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 9
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:torek-lintar"
|
||||||
|
relationship: PersonOfInterest
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical, pattern-recognition]
|
||||||
|
notes: >
|
||||||
|
Alternate Phase 3 — analytical register throughout, no personal register even for Sera
|
||||||
|
(surname only). 'Worth seeing if those connect' is the detective's verbal tic of flagging
|
||||||
|
without concluding. The numbered format ('Two facts... One new observation') is the
|
||||||
|
detective's internal case-building style. Fully non-accusatory: the connection is not
|
||||||
|
stated, only the intent to look for it.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_con_008
|
||||||
|
text: "Venn's been managing her exits around Lintar. And she put Kael somewhere he isn't. Let's see what sits at the intersection of those two."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, alone]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:sera-venn"
|
||||||
|
state: Contradicted
|
||||||
|
- id: "npc:torek-lintar"
|
||||||
|
relationship: PersonOfInterest
|
||||||
|
tags: [contradiction, friend-arc, sera, kael, phase-3, analytical]
|
||||||
|
notes: >
|
||||||
|
Alternate Phase 3. 'Let's see what' verbal tic, no accusation. 'Managing her exits' is
|
||||||
|
the detective naming Sera's avoidance behavior — behavioral description, not motive
|
||||||
|
attribution. 'Put Kael somewhere he isn't' — plain contradiction framing. The intersection
|
||||||
|
remains unstated. Detective is following the thread, not announcing what's on it.
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
character: detective
|
||||||
|
location: tutorial
|
||||||
|
lines:
|
||||||
|
# Diegetic Tutorial Monologue — Ticket #330 — Detective
|
||||||
|
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
|
||||||
|
#
|
||||||
|
# These lines teach mechanics through character voice — not UI instructions.
|
||||||
|
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
|
||||||
|
# These fire on first-time events throughout gameplay, wherever they occur.
|
||||||
|
#
|
||||||
|
# Coverage:
|
||||||
|
# Movement / exploration — first_move, first_new_section
|
||||||
|
# Fog of perception — first_fog_encounter, first_fog_edge
|
||||||
|
# Sound model — first_sound_heard, first_off_screen_sound
|
||||||
|
# NPC interaction — first_npc_proximity, first_npc_face_read
|
||||||
|
# Insert / HUD — first_insert_open, first_insert_contact_flag
|
||||||
|
#
|
||||||
|
# Voice: analytical, procedure-oriented, careful. No contractions in analytical mode.
|
||||||
|
# Colon usage for categorization. 'Let's see what...' and 'Worth flagging' verbal tics.
|
||||||
|
# ID discriminator: _tut_ per sprint briefing.
|
||||||
|
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
|
||||||
|
# fire once per game), not by a cooldown value.
|
||||||
|
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
|
||||||
|
#
|
||||||
|
# Schema corrections (Gestalt review, Sprint 17):
|
||||||
|
# priority: string "tutorial" → integer. All other monologue files use integer priority;
|
||||||
|
# string value would fail engine parse in monologue.rs.
|
||||||
|
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
|
||||||
|
# enum. Replaced with arrival, routine per actual trigger context.
|
||||||
|
#
|
||||||
|
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
|
||||||
|
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
|
||||||
|
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
|
||||||
|
# D-035 v0.1 set — require server-side implementation in monologue.rs.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# MOVEMENT / EXPLORATION
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_001
|
||||||
|
text: "Signage: section designators on the corridor wall. Commission-standard markings. Worth learning before the district learns me."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [arrival, routine]
|
||||||
|
trigger: first_move
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, movement, navigation, analytical]
|
||||||
|
notes: >
|
||||||
|
Detective version of the 'check the signage' tutorial. Colon usage for the signage
|
||||||
|
label (section 2.2 verbal pattern). 'Commission-standard markings' — institutional
|
||||||
|
framing; he reads the environment through his professional lens first. 'Worth learning
|
||||||
|
before the district learns me' — dry awareness of his asymmetric visibility in this
|
||||||
|
community. Same teaching as the smuggler version, different cognitive frame.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_002
|
||||||
|
text: "Three sections in range from the main terminal. Commission briefing had the layout. Let's confirm it against the floor."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [arrival, investigation]
|
||||||
|
trigger: first_new_section
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, movement, navigation, analytical]
|
||||||
|
notes: >
|
||||||
|
Fires on entering a new section for the first time. 'Commission briefing had the
|
||||||
|
layout' — he arrived with a map, now he's verifying it against reality. 'Let's
|
||||||
|
confirm it against the floor' — 'Let's' verbal tic, investigative method stated as
|
||||||
|
principle: documentation is a starting point, not a substitute for observation.
|
||||||
|
Teaches: the district has distinct sections to explore.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# FOG OF PERCEPTION
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_003
|
||||||
|
text: "Visual range terminates at the corridor junction. Consistent with district atmo settings. Anything past that point: unconfirmed."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_fog_encounter
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, fog, perception, analytical]
|
||||||
|
notes: >
|
||||||
|
Detective fog tutorial — analytical register throughout. 'Visual range terminates at'
|
||||||
|
instead of 'Can't see past.' 'Consistent with atmo settings' — he already knows this
|
||||||
|
is an environmental property, not an anomaly. 'Anything past that point: unconfirmed'
|
||||||
|
— colon usage, precise epistemic status. Teaches: perception boundary exists, and
|
||||||
|
the detective categorizes it as an information state, not a barrier. Compare to
|
||||||
|
smuggler's 'Might be worth checking' — same fog, different response.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_004
|
||||||
|
text: "Can't clear the far bay from here. Going around is the procedure — not a limitation, a working condition."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_fog_edge
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, fog, perception, analytical]
|
||||||
|
notes: >
|
||||||
|
Second fog encounter line — fires at a fog zone boundary. 'Going around is the
|
||||||
|
procedure' — detective frames the limitation as methodology. The second clause
|
||||||
|
('not a limitation, a working condition') is self-instruction: he's telling himself
|
||||||
|
not to treat fog as frustrating but as standard operating environment. Teaches:
|
||||||
|
perception range is persistent throughout gameplay, and the correct response is
|
||||||
|
movement, not waiting.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# SOUND MODEL
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_005
|
||||||
|
text: "Voices. Down the corridor. Source count indeterminate from here — distance degrades clarity."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_sound_heard
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, sound, perception, analytical]
|
||||||
|
notes: >
|
||||||
|
Detective sound tutorial — analytical framing of the briefing's example. 'Source count
|
||||||
|
indeterminate' is the precise version of 'can't tell how many.' 'Distance degrades
|
||||||
|
clarity' — technical, causal. He hears, notes what he can establish (voices, direction)
|
||||||
|
and what he can't (count, content). No contractions. Teaches: sound channel is active
|
||||||
|
and distance-limited. Compare to smuggler's 'Can't make out the words' — same acoustic
|
||||||
|
reality, systematized differently.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_006
|
||||||
|
text: "Footsteps. Metal-grated surface. Two sets minimum — moving away. Service access corridor, by the acoustic signature."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_off_screen_sound
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, sound, perception, analytical]
|
||||||
|
notes: >
|
||||||
|
Second sound tutorial — fires on an off-screen audio source. The detective parses
|
||||||
|
the sound through multiple analytical dimensions: surface type, count, direction,
|
||||||
|
room type. 'Two sets minimum' — precise but not overclaiming. 'By the acoustic
|
||||||
|
signature' is technical terminology applied to diegetic audio interpretation.
|
||||||
|
Teaches: sound carries more than presence — floor type, distance, count, direction
|
||||||
|
are all extractable. The detective extracts all of them automatically.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# NPC INTERACTION
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_007
|
||||||
|
text: "Civilians in range. Standard approach: observe before engaging. Let them surface what's relevant before directing the conversation."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [social, observation]
|
||||||
|
trigger: first_npc_proximity
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, npc, interaction, analytical]
|
||||||
|
notes: >
|
||||||
|
Detective NPC tutorial — procedural, method-as-principle. 'Civilians in range' is
|
||||||
|
his categorization (Commission-trained: everyone is a witness category until proven
|
||||||
|
otherwise). 'Standard approach' — he has a protocol. 'Let them surface what's relevant'
|
||||||
|
is the investigative principle: don't prime witnesses, let them self-select information.
|
||||||
|
No contractions throughout. Teaches: NPC interaction is player-initiated and
|
||||||
|
approach-sensitive. Different method than the smuggler ('give them reason to') —
|
||||||
|
same outcome, different social model.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_008
|
||||||
|
text: "Commission credentials flag on approach. They know who I am before I speak. Worth noting who adjusts their behavior."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [social, investigation]
|
||||||
|
trigger: first_npc_face_read
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, npc, observation, analytical, authority]
|
||||||
|
notes: >
|
||||||
|
Fires when the player observes an NPC at close range. The detective's asymmetry:
|
||||||
|
his authority access tier makes him visible in ways the smuggler is not. NPCs react
|
||||||
|
to his presence before interaction begins. 'Worth noting who adjusts their behavior'
|
||||||
|
— 'Worth' verbal tic, sets up behavioral observation as investigation method. Teaches:
|
||||||
|
the detective's Commission credentials affect NPC behavior, and that behavioral
|
||||||
|
response is data.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# INSERT / HUD
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_009
|
||||||
|
text: "Lattice overlay active: time, contact positions, flagged entities. Commission-linked. Standard procedure to keep it running."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [routine]
|
||||||
|
trigger: first_insert_open
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, insert, hud, analytical]
|
||||||
|
notes: >
|
||||||
|
Detective insert tutorial. Colon usage: catalogs what the overlay contains. 'Commission-
|
||||||
|
linked' — institutional framing, establishes why the detective's HUD is denser than
|
||||||
|
the smuggler's. 'Standard procedure' — not a discovery, a protocol. Teaches: the
|
||||||
|
insert is an active investigation tool, not optional. Compare to smuggler's 'Should
|
||||||
|
show the time and nearby contacts' — same overlay, presented as procedural requirement
|
||||||
|
vs. operational check.
|
||||||
|
|
||||||
|
- id: pc-detective_m_d_tut_010
|
||||||
|
text: "Nine identity flags in range. Cross-referencing case file now. Let's see what matches."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, routine]
|
||||||
|
trigger: first_insert_contact_flag
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, insert, hud, lattice, investigation]
|
||||||
|
notes: >
|
||||||
|
Fires when the insert first populates with flagged entities. Count given precisely
|
||||||
|
('Nine identity flags'). 'Case file' — the detective's overlay cross-references a
|
||||||
|
pre-loaded investigation file, not just a general registry. 'Let's see what matches'
|
||||||
|
— 'Let's see what' verbal tic closing the tutorial sequence. Teaches: the detective's
|
||||||
|
insert is investigation-keyed, matching observed faces against case file entries.
|
||||||
|
This is why his overlay is analytically denser than the smuggler's.
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
character: smuggler
|
||||||
|
location: contradiction
|
||||||
|
lines:
|
||||||
|
# Contradiction Monologue — Ticket #552 — Sera/Kael FRIEND Arc (Smuggler perspective)
|
||||||
|
# Authored per D-083 (contradiction detection pipeline) and Paula Round 2 Mellanie spec
|
||||||
|
# (docs/workshops/knowledge-flow-npc-boundaries/paula-round2.md, lines 117-158).
|
||||||
|
#
|
||||||
|
# Trigger: contradiction_detected fires when the smuggler observes Kael in Corridor B-7
|
||||||
|
# with an unrecognized contact — contradicting Kael's stated location and ring protocol.
|
||||||
|
# Event payload: source_display_name = "Kael Davan", subject_display_name = "Kael Davan"
|
||||||
|
# (Kael is both source and subject: he placed himself elsewhere, smuggler sees him here.)
|
||||||
|
#
|
||||||
|
# FRIEND-pattern NPCs always use hand-authored lines — generic template fallback
|
||||||
|
# does not fire for this arc.
|
||||||
|
#
|
||||||
|
# Authoring principle: cognitive dissonance, not accusation. The smuggler reads social
|
||||||
|
# signals instinctively, but doesn't conclude — she contains the alarm. Street-smart
|
||||||
|
# gut reaction, not analytical procedure.
|
||||||
|
#
|
||||||
|
# Phase 2 lines: first observation — Kael in B-7 with unknown contact.
|
||||||
|
# Prerequisite: KG entry for Kael has state: Contradicted, source: ToldBy(Kael Davan)
|
||||||
|
# Phase 3 lines: behavioral pattern already accumulating — increased lattice checking,
|
||||||
|
# shortened interactions, early exits from break room.
|
||||||
|
# Prerequisite: Phase 2 + Kael's behavioral anomalies observed (count >= 2).
|
||||||
|
#
|
||||||
|
# ID discriminator: _con_ marks contradiction lines per Q-028 pending resolution.
|
||||||
|
# cooldown: omitted — fire-once enforced by trigger semantics (contradiction_detected
|
||||||
|
# fires once per KG state change), not by a cooldown value.
|
||||||
|
#
|
||||||
|
# Note: contradiction_detected is a new trigger type — requires server-side implementation
|
||||||
|
# in monologue.rs (ticket #550). Secondary beat lines use lower priority for sequencing;
|
||||||
|
# server team should implement delay_after_trigger_ticks for proper 3-5 second gap.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# PHASE 2 — Primary beat (fires on contradiction_detected, immediate)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_001
|
||||||
|
text: "Kael said second shift was clear. He's in B-7. With someone I don't know."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 10
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-2, operational]
|
||||||
|
notes: >
|
||||||
|
Primary line — three beats, three facts. 'Kael said' names the source (friend register,
|
||||||
|
first name). 'He's in B-7' is the observation. 'With someone I don't know' is the alarm:
|
||||||
|
not just wrong location, but outside the ring. Fragment structure throughout — the smuggler
|
||||||
|
clocks a scene in pieces. No accusation, no conclusion: just inventory of what's wrong.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_002
|
||||||
|
text: "That's Kael. Restricted corridor. Unrecognized contact. Kael doesn't come to B-7."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 9
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-2, operational]
|
||||||
|
notes: >
|
||||||
|
Alternate primary — catalog format. 'That's Kael' opens with identification, not alarm:
|
||||||
|
recognition first, then the wrong details fall into place. Each fragment adds one layer
|
||||||
|
of what's wrong. Close: 'Kael doesn't come to B-7' — negative statement of prior
|
||||||
|
expectation, which is now violated. No contraction in the closing declarative (emphasis).
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_003
|
||||||
|
text: "He's in B-7. He told me he'd be heading home after shift. One of those isn't the case."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 9
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-2]
|
||||||
|
notes: >
|
||||||
|
Alternate primary — echoes the Paula canonical structure (observation / prior claim /
|
||||||
|
contradiction stated) but in the smuggler's voice. 'He told me' is personal register
|
||||||
|
(friend, not colleague). 'One of those isn't the case' — the smuggler's understated
|
||||||
|
version of flagging a contradiction: not 'one of them is a lie' but 'isn't the case.'
|
||||||
|
Epistemic containment in plain language.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# PHASE 2 — Secondary beat (gut-level recalibration, operational containment)
|
||||||
|
# Server team (#550): implement delay_after_trigger_ticks: 90 for 3-5s gap.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_004
|
||||||
|
text: "Maybe there's a reason. There's always a reason. But Kael knows better than to run a meeting in a maintenance corridor without telling me."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, alone]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 7
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-2, cognitive-dissonance, secondary-beat]
|
||||||
|
notes: >
|
||||||
|
Secondary beat — fires 3-5 seconds after primary. Self-challenge first: the smuggler
|
||||||
|
gives Kael benefit of the doubt, then immediately walks it back with operational
|
||||||
|
logic. 'There's always a reason' is the street-smart equivalent of epistemic
|
||||||
|
containment — she's seen strange things before. The closing line is where the alarm
|
||||||
|
lives: this isn't strange, it's a protocol violation.
|
||||||
|
Lower priority (7) ensures primary lines fire first; server team should implement
|
||||||
|
delay_after_trigger_ticks for proper sequencing.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_005
|
||||||
|
text: "Don't assume. Could be he's running something for Nils I wasn't told about. Could be something else. The second thing is the problem."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, alone]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-2, operational, cognitive-dissonance, secondary-beat]
|
||||||
|
notes: >
|
||||||
|
Alternate secondary beat. 'Don't assume' is the smuggler's self-instruction (operational
|
||||||
|
discipline). Two possibilities held open: ring-authorized (names Nils — the coordinator)
|
||||||
|
vs. outside the ring. 'The second thing is the problem' — the alarm, stated as dry
|
||||||
|
understatement. Operational logic applied to an emotional moment: the smuggler
|
||||||
|
keeps the processing clean even when the stakes are personal.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# PHASE 3 — Behavioral pattern accumulated (increased lattice checking, early exits)
|
||||||
|
# Requires: Kael behavioral anomalies observed prior to the B-7 contradiction.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_006
|
||||||
|
text: "He's been distracted for three days. Lattice checks every few minutes, leaving lunch early. Now a B-7 meeting with someone outside the ring. Kael's working an angle he hasn't told me about."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused, anxious]
|
||||||
|
priority: 10
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tells_observed: 2
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-3, pattern-recognition, operational]
|
||||||
|
notes: >
|
||||||
|
Phase 3 primary — connects all accumulated behavioral data to the contradiction. Fragment
|
||||||
|
catalog builds the picture: three days of signals, then this. Closing line names the
|
||||||
|
conclusion without naming its implication: 'working an angle' is street-smart language
|
||||||
|
for running something unauthorized, but the smuggler doesn't say 'exit attempt' —
|
||||||
|
she doesn't know that yet.
|
||||||
|
Note: tells_observed is a proposed prerequisite field — server team (#550) to implement
|
||||||
|
or simplify to a different check.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_007
|
||||||
|
text: "Kael doesn't run his own contacts. Devra handles external. So either Devra knows about this, or Kael's outside protocol. Neither's good."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [focused]
|
||||||
|
priority: 9
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tells_observed: 2
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-3, operational]
|
||||||
|
notes: >
|
||||||
|
Alternate Phase 3 — operational knowledge applied. The smuggler knows ring structure:
|
||||||
|
Devra manages external contacts, Kael handles cargo logistics. An unrecognized contact
|
||||||
|
means either Devra is running something Kael's involved in (plausible, alarming) or
|
||||||
|
Kael went outside protocol (worse). Both possibilities named, neither closed. 'Neither's
|
||||||
|
good' — the smuggler's understated close for a significant threat read.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_con_008
|
||||||
|
text: "He's been somewhere else in his head all week. Now I know why. Whoever that was — not ring. Kael's looking for a way out."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [investigation, observation]
|
||||||
|
trigger: contradiction_detected
|
||||||
|
mood: [anxious]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
prerequisites:
|
||||||
|
entities:
|
||||||
|
- id: "npc:kael-davan"
|
||||||
|
state: Contradicted
|
||||||
|
tells_observed: 2
|
||||||
|
tags: [contradiction, friend-arc, kael, phase-3, intuition]
|
||||||
|
notes: >
|
||||||
|
Alternate Phase 3 — gut read, highest emotional weight. 'Somewhere else in his head'
|
||||||
|
describes the behavioral changes without cataloging them. 'Now I know why' — the click
|
||||||
|
of understanding arriving. 'Not ring' — short, certain, two words. 'Looking for a way
|
||||||
|
out' is street-smart language for exit attempt: the smuggler doesn't know the details
|
||||||
|
but the read is right. This line is the closest the smuggler comes to naming what's
|
||||||
|
happening — and she still doesn't say it explicitly.
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
character: smuggler
|
||||||
|
location: tutorial
|
||||||
|
lines:
|
||||||
|
# Diegetic Tutorial Monologue — Ticket #330 — Smuggler
|
||||||
|
# Authored per sprint-17 copy briefing and D-016 (internal monologue as core system).
|
||||||
|
#
|
||||||
|
# These lines teach mechanics through character voice — not UI instructions.
|
||||||
|
# Distinct from opening.yaml (which covers the first 5 minutes and sets voice/motivation).
|
||||||
|
# These fire on first-time events throughout gameplay, wherever they occur.
|
||||||
|
#
|
||||||
|
# Coverage:
|
||||||
|
# Movement / exploration — first_move, first_new_section
|
||||||
|
# Fog of perception — first_fog_encounter, first_fog_edge
|
||||||
|
# Sound model — first_sound_heard, first_off_screen_sound
|
||||||
|
# NPC interaction — first_npc_proximity, first_npc_face_read
|
||||||
|
# Insert / HUD — first_insert_open, first_insert_contact_flag
|
||||||
|
#
|
||||||
|
# Voice: observational, street-smart, practical. Fragments. Standalone "Good." as tic.
|
||||||
|
# No analytical distance — the smuggler reads, reacts, files, moves on.
|
||||||
|
# ID discriminator: _tut_ per sprint briefing.
|
||||||
|
# cooldown: omitted — fire-once enforced by trigger semantics (first_* triggers
|
||||||
|
# fire once per game), not by a cooldown value.
|
||||||
|
# priority: 8 (primary, one per trigger type), 6 (secondary variant, mood-weighted alternate).
|
||||||
|
#
|
||||||
|
# Schema corrections (Gestalt review, Sprint 17):
|
||||||
|
# priority: string "tutorial" → integer. All other monologue files use integer priority;
|
||||||
|
# string value would fail engine parse in monologue.rs.
|
||||||
|
# situation: removed invalid values (movement, exploration) not in D-035 13-situation
|
||||||
|
# enum. Replaced with arrival, routine, observation per actual trigger context.
|
||||||
|
# mood: [focused] retained where set; valid per D-035 Sprint 14 amendment.
|
||||||
|
#
|
||||||
|
# Trigger types (first_move, first_new_section, first_fog_encounter, first_fog_edge,
|
||||||
|
# first_sound_heard, first_off_screen_sound, first_npc_proximity, first_npc_face_read,
|
||||||
|
# first_insert_open, first_insert_contact_flag) are extended trigger enum values not in
|
||||||
|
# D-035 v0.1 set — require server-side implementation in monologue.rs.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# MOVEMENT / EXPLORATION
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_001
|
||||||
|
text: "These corridors all look the same. Mental note: check the signage."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [arrival, routine]
|
||||||
|
trigger: first_move
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, movement, navigation]
|
||||||
|
notes: >
|
||||||
|
Directly from the sprint briefing example. First move teaches navigation: the district
|
||||||
|
looks homogeneous, signage is the tool. 'Mental note' is the smuggler's internal
|
||||||
|
flag for things to remember — operational housekeeping, not analysis. Establishes
|
||||||
|
early: this environment requires active spatial tracking.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_002
|
||||||
|
text: "Two lefts past the junction, right at the supply hatch. That's dock four. Route's in the muscle now."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [arrival, routine]
|
||||||
|
trigger: first_new_section
|
||||||
|
mood: [content]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, movement, navigation, operational]
|
||||||
|
notes: >
|
||||||
|
Fires on entering a new section for the first time. 'Route's in the muscle' — the
|
||||||
|
smuggler doesn't memorize spatially, she moves until it's automatic. Practical,
|
||||||
|
self-sufficient. Teaches: the district has multiple sections, each requires its own
|
||||||
|
orientation. 'Dock four' is a landmark. Forward movement is rewarded with familiarity.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# FOG OF PERCEPTION
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_003
|
||||||
|
text: "Can't see past that corner. Might be worth checking."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_fog_encounter
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, fog, perception]
|
||||||
|
notes: >
|
||||||
|
Directly from the sprint briefing example. First fog boundary. Restated as habit:
|
||||||
|
the smuggler doesn't treat the fog as a limitation, she treats every unknown corner
|
||||||
|
as a question worth answering. 'Might be worth' — not paranoia, just operational
|
||||||
|
instinct. Teaches: the perception boundary is navigable, not fixed.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_004
|
||||||
|
text: "Haze cuts off past the junction. Anything in it — can't say."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_fog_edge
|
||||||
|
mood: [focused]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, fog, perception]
|
||||||
|
notes: >
|
||||||
|
Second fog encounter line — fires when the player reaches the boundary of a fog zone
|
||||||
|
(the edge of visible range). 'Can't say' — plain statement of unknowing, not anxiety.
|
||||||
|
The smuggler accepts incomplete information as a working condition, not a failure state.
|
||||||
|
Teaches: perception range is an ongoing factor, not a one-time obstacle.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# SOUND MODEL
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_005
|
||||||
|
text: "Voices down the hall. Can't make out the words from here."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_sound_heard
|
||||||
|
mood: [focused]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, sound, perception]
|
||||||
|
notes: >
|
||||||
|
Directly from the sprint briefing example. First sound heard that's not immediately
|
||||||
|
visible. Teaching: sound carries information, but range and occlusion limit it.
|
||||||
|
'Can't make out the words' establishes both the value of the sound channel (voices =
|
||||||
|
people = relevant) and its limitation (unclear at range). Moving toward source is
|
||||||
|
the implied next step.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_006
|
||||||
|
text: "Footsteps. Two sets. Moving away — good."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [observation]
|
||||||
|
trigger: first_off_screen_sound
|
||||||
|
mood: [content]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, sound, perception, operational]
|
||||||
|
notes: >
|
||||||
|
Second sound line — fires when audio from an off-screen source is detected. The
|
||||||
|
smuggler parses footsteps as count and direction instinctively. 'Moving away — good.'
|
||||||
|
— the standalone 'Good.' tic applied to tactical assessment. Teaches: sound carries
|
||||||
|
directional and movement data, not just presence. Two sets = two people, which is
|
||||||
|
specific enough to be operationally useful.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# NPC INTERACTION
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_007
|
||||||
|
text: "Could ask around. People talk if you give them reason to."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [social, observation]
|
||||||
|
trigger: first_npc_proximity
|
||||||
|
mood: [content]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, npc, interaction, social]
|
||||||
|
notes: >
|
||||||
|
Directly from the sprint briefing example. First time in range of an NPC. The smuggler
|
||||||
|
doesn't approach directly — she flags the option. 'Give them reason to' is street-smart
|
||||||
|
social knowledge: people don't volunteer freely, they need a hook. Teaches: NPC
|
||||||
|
interaction is player-initiated, and approach matters. No mechanics text — just the
|
||||||
|
character's own method stated as personal principle.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_008
|
||||||
|
text: "Know the face. Don't need the name."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [social, observation]
|
||||||
|
trigger: first_npc_face_read
|
||||||
|
mood: [content]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, npc, observation, social]
|
||||||
|
notes: >
|
||||||
|
Fires when the player observes an NPC at close range (face visible). The smuggler
|
||||||
|
reads people by face, not by name — the name comes later if relevant. Teaches: NPCs
|
||||||
|
are identifiable by observation, and the insert overlay will fill in names when
|
||||||
|
available. The smuggler's social intelligence is pre-lattice, instinctive.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# INSERT / HUD
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_009
|
||||||
|
text: "Check the overlay. Should show the time and nearby contacts."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [routine]
|
||||||
|
trigger: first_insert_open
|
||||||
|
mood: [content]
|
||||||
|
priority: 8
|
||||||
|
|
||||||
|
tags: [tutorial, insert, hud, operational]
|
||||||
|
notes: >
|
||||||
|
Directly from the sprint briefing example. First insert/HUD open. The smuggler's
|
||||||
|
overlay shows time and nearby contacts — both operationally critical. 'Should show'
|
||||||
|
— practical, not certain. Teaches: the insert is the primary tool for time-tracking
|
||||||
|
and NPC awareness. Diegetic: she checks it the same way she checks a shift board.
|
||||||
|
|
||||||
|
- id: pc-smuggler_m_s_tut_010
|
||||||
|
text: "Overlay marks Kael two sections over. Good."
|
||||||
|
role: player_character
|
||||||
|
access: [public]
|
||||||
|
trust: surface
|
||||||
|
situation: [routine, social]
|
||||||
|
trigger: first_insert_contact_flag
|
||||||
|
mood: [content, warm]
|
||||||
|
priority: 6
|
||||||
|
|
||||||
|
tags: [tutorial, insert, hud, kael, friend-arc]
|
||||||
|
notes: >
|
||||||
|
Fires when the insert first flags a known contact (here: Kael, the FRIEND NPC).
|
||||||
|
'Two sections over' — spatial data from the overlay. 'Good.' standalone tic —
|
||||||
|
warm, settling. Teaches: the insert shows known NPC positions in real-time.
|
||||||
|
Also seeds the Kael relationship: the smuggler checks his location as a habit,
|
||||||
|
not a task. His position is always relevant to her.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Fact catalog: ring
|
||||||
|
# Smuggling ring module event trail — observable evidence produced by module events.
|
||||||
|
# These facts are made discoverable by specific events in smuggling_ring_v0_1.yaml.
|
||||||
|
# Stub entries — description and confidence level confirmed; progression text TBD.
|
||||||
|
# Ticket: #158 | Sprint 18
|
||||||
|
|
||||||
|
facts:
|
||||||
|
|
||||||
|
- fact_id: ring.cargo_discrepancy_pattern
|
||||||
|
description: Cargo manifests at The Terminal show small, systematic discrepancies — weight variances, unverified entries, containers with no return log
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Detective's analytical lattice may flag this automatically during Terminal walkthrough.
|
||||||
|
Smuggler encounters it during operational paperwork checks. Pattern becomes clearer over
|
||||||
|
multiple observations (once: false event — repeats). Starting entry point for both
|
||||||
|
investigation arcs.
|
||||||
|
|
||||||
|
- fact_id: ring.kael_missed_verification
|
||||||
|
description: Kael Davan was absent from a scheduled cargo verification — another dock worker covered without explanation
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Discoverable via cargo manifest review (Terminal office) or analytical lattice flag
|
||||||
|
(detective). Smuggler notices the irregularity in their own paperwork. First concrete
|
||||||
|
evidence that Kael is deviating from his normal operational reliability.
|
||||||
|
|
||||||
|
- fact_id: ring.kael_unauthorized_corridor_access
|
||||||
|
description: Kael was observed in restricted maintenance corridor B-7 during off-shift hours with an unknown contact
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Core FRIEND contradiction observable (D-034). Player must be in visual range of B-7
|
||||||
|
to discover this, OR examine the corridor door access log (investigative action).
|
||||||
|
Both characters can discover this fact — interpreted differently (smuggler: security
|
||||||
|
breach / detective: suspicious contact). Upgrades from suspects to knows_of when
|
||||||
|
contact identity confirmed, knows_details when purpose confirmed.
|
||||||
|
|
||||||
|
- fact_id: ring.kael_unusual_meridian_activity
|
||||||
|
description: Encrypted Meridian packets sent from cargo bay terminals — frequent, patterned, not from personal devices
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [detective]
|
||||||
|
notes: >
|
||||||
|
Detective-only. Requires analytical lattice to flag the outgoing packet pattern.
|
||||||
|
Player must be in or adjacent to The Terminal cargo bay. Content not accessible —
|
||||||
|
only the existence and frequency of the encrypted messages. Hints at Kael's
|
||||||
|
off-district contact without revealing who.
|
||||||
|
|
||||||
|
- fact_id: ring.voss_kael_private_meeting
|
||||||
|
description: Voss called Kael into the supervisor's office — closed door, raised voices audible from adjacent position
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Observable via spatial positioning (supervisor's office area). Smuggler can witness
|
||||||
|
the approach and departure without hearing content. Detective observes Kael's visible
|
||||||
|
strain immediately after if present in The Terminal. Upgrade to knows_of via
|
||||||
|
trust-gated dialogue with Kael ("Are you alright?" option unlocks post-meeting).
|
||||||
|
|
||||||
|
- fact_id: ring.voss_accelerating_timeline
|
||||||
|
description: Voss is pushing to close ring operations faster than normal — someone is watching
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Smuggler: Voss mentions "the schedule moving up" in guarded conversation (trust-gated,
|
||||||
|
ring-insider access required). Detective: observe Voss and Nils in two exchanges within
|
||||||
|
the same shift and note behavioral change. Not directly stated — inferred from context.
|
||||||
|
|
||||||
|
- fact_id: ring.commission_cargo_flag
|
||||||
|
description: A Commission internal note flags The Terminal's cargo variance rate as statistically unusual
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [detective]
|
||||||
|
notes: >
|
||||||
|
Detective-only. Accessible via institutional Commission query (authority access tier,
|
||||||
|
Terminal records). Not a formal investigation — just an internal flag from Maret Korr.
|
||||||
|
Confirms the detective's suspicions with institutional weight. Smuggler has no way to
|
||||||
|
know this exists.
|
||||||
|
|
||||||
|
- fact_id: ring.final_shipment_scheduled
|
||||||
|
description: The ring has scheduled a major final drop — after it clears, operations go dark
|
||||||
|
typical_confidence: knows_of
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Smuggler: direct notification from Voss (ring-insider access). Detective: cargo manifest
|
||||||
|
shows an unusually large entry scheduled 3 days out — no obvious legitimate reason for
|
||||||
|
the volume. This fact creates the closing window — both characters know time is running
|
||||||
|
out, for different reasons.
|
||||||
|
|
||||||
|
- fact_id: ring.sera_avoidance_behavior
|
||||||
|
description: Sera Venn takes longer routes through The Last Shift to avoid standing near Torek Lintar
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [detective]
|
||||||
|
notes: >
|
||||||
|
Detective-only observable. Player must observe Sera's movement pattern on two separate
|
||||||
|
occasions — requires forward vision cone and awareness of her baseline route. The
|
||||||
|
avoidance is visible but not explained. Points toward Sera holding information about
|
||||||
|
Torek's investigation without having acted on it.
|
||||||
|
|
||||||
|
- fact_id: ring.nils_unlogged_cargo
|
||||||
|
description: Nils makes regular supply runs to maintenance corridor C-7 with containers that never appear in return logs
|
||||||
|
typical_confidence: suspects
|
||||||
|
characters: [smuggler, detective]
|
||||||
|
notes: >
|
||||||
|
Discoverable by watching Nils's cargo handling pattern over two shifts, or by examining
|
||||||
|
the maintenance corridor C-7 access log directly. The missing return entry is the tell —
|
||||||
|
the container went in but never came back. Both characters can find this; smuggler may
|
||||||
|
already suspect given operational context.
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml
|
||||||
|
#
|
||||||
|
# Tier 1 Drama Module: The Smuggling Ring (v0.1)
|
||||||
|
# The vertical slice Tier 1 module for D-027.
|
||||||
|
#
|
||||||
|
# NARRATIVE CORE:
|
||||||
|
# A logistics worker (the smuggler PC, if played) is embedded in a small ring
|
||||||
|
# smuggling unlicensed lattice components through Sova Transit District (D-037).
|
||||||
|
# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the
|
||||||
|
# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm.
|
||||||
|
# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies
|
||||||
|
# but hasn't reported them, protecting Naia by proxy.
|
||||||
|
#
|
||||||
|
# DUAL-LENS EXPERIENCE:
|
||||||
|
# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold.
|
||||||
|
# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting →
|
||||||
|
# confront or protect.
|
||||||
|
#
|
||||||
|
# SUCCESS CRITERIA (D-027):
|
||||||
|
# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100)
|
||||||
|
# #3: Player names Kael as an NPC they felt conflicted about
|
||||||
|
# #4: observe→notice→follow→discover emerges from systems, not scripts
|
||||||
|
|
||||||
|
module_id: smuggling_ring_v0_1
|
||||||
|
display_name: "The Smuggling Ring"
|
||||||
|
version: "0.1"
|
||||||
|
tier: 1
|
||||||
|
description: >
|
||||||
|
A small ring of logistics workers smuggling unlicensed lattice components through
|
||||||
|
Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit
|
||||||
|
to protect his partner. The detective investigates cargo anomalies. The smuggler
|
||||||
|
manages ring operations and navigates Kael's loyalty crisis. Neither character
|
||||||
|
knows the other's full picture until confrontation forces it.
|
||||||
|
|
||||||
|
notes: >
|
||||||
|
This module IS the vertical slice (D-027). It exercises every system at full depth:
|
||||||
|
dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph
|
||||||
|
confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064),
|
||||||
|
and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned.
|
||||||
|
No outcome is "the right answer" — Kael's situation has no clean resolution.
|
||||||
|
|
||||||
|
dual_lens:
|
||||||
|
smuggler: >
|
||||||
|
You're inside the ring. Voss manages operations; you handle logistics cover.
|
||||||
|
Kael used to be reliable. Lately he's absent, distracted, making excuses.
|
||||||
|
The drop schedule is at risk. Do you pressure him, cover for him, or cut him?
|
||||||
|
You don't know he's trying to get out. He doesn't know you've noticed.
|
||||||
|
detective: >
|
||||||
|
Cargo manifest discrepancies in The Terminal. Small, systematic, deniable.
|
||||||
|
Your analytical lattice flags them before your conscious mind does.
|
||||||
|
Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan →
|
||||||
|
maintenance corridors → someone he shouldn't be meeting. And then what?
|
||||||
|
Arrest a man trying to leave a ring he never wanted to join?
|
||||||
|
|
||||||
|
pool:
|
||||||
|
weight: 8
|
||||||
|
compatible_districts:
|
||||||
|
- sova-transit
|
||||||
|
max_concurrent: 1
|
||||||
|
|
||||||
|
# ── ENTRY CONDITIONS ─────────────────────────────────────────────────────────
|
||||||
|
# Ring activity begins after player has had time to establish routine (D-027 #1).
|
||||||
|
# The ring is already running at game start — the module activates when the
|
||||||
|
# storyteller decides the tension has built enough to surface.
|
||||||
|
|
||||||
|
entry_conditions:
|
||||||
|
world_state:
|
||||||
|
- type: npc_present
|
||||||
|
role: ring-leader
|
||||||
|
- type: npc_present
|
||||||
|
role: ring-member-exiting
|
||||||
|
- type: location_accessible
|
||||||
|
location: the-terminal
|
||||||
|
- type: location_accessible
|
||||||
|
location: maintenance-corridors
|
||||||
|
|
||||||
|
activation:
|
||||||
|
trigger: storyteller_push
|
||||||
|
min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1
|
||||||
|
# The storyteller pushes activation when player has established presence
|
||||||
|
# in The Terminal or The Last Shift through routine interaction.
|
||||||
|
# Proximity trigger (maintenance-corridors) is a secondary activation path
|
||||||
|
# if the player wanders there early.
|
||||||
|
|
||||||
|
# ── NPC REQUIREMENTS ─────────────────────────────────────────────────────────
|
||||||
|
# All core roles are named (hand-authored NPCs from the vertical slice).
|
||||||
|
# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs.
|
||||||
|
|
||||||
|
npc_requirements:
|
||||||
|
- role: ring-leader
|
||||||
|
display_hint: >
|
||||||
|
Runs the ring from The Terminal. Logistics authority = cover.
|
||||||
|
Never handles contraband directly. Pressure source for Kael.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:voss"
|
||||||
|
must_have_motivation: HANDLER
|
||||||
|
|
||||||
|
- role: ring-member-exiting
|
||||||
|
display_hint: >
|
||||||
|
Kael Davan. Dock worker, ring member, smuggler's FRIEND.
|
||||||
|
Trying to exit quietly to protect Naia. This is THE FRIEND contradiction.
|
||||||
|
Every event sequence runs through this role.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:kael-davan"
|
||||||
|
must_have_pattern: FRIEND
|
||||||
|
must_have_motivation: TURNCOAT
|
||||||
|
|
||||||
|
- role: partner-uninvolved
|
||||||
|
display_hint: >
|
||||||
|
Naia Tamm. Kael's partner. Does not know about the ring.
|
||||||
|
Her safety is Kael's motivation for exiting. Her ignorance is the moral weight.
|
||||||
|
Discovery of her connection to Kael is a late-investigation revelation.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:naia-tamm"
|
||||||
|
must_have_motivation: CIVILIAN
|
||||||
|
|
||||||
|
- role: evidence-holder
|
||||||
|
display_hint: >
|
||||||
|
Sera Venn. Detective's FRIEND. Commission field tech.
|
||||||
|
She has noticed Kael's manifest discrepancies but hasn't reported them —
|
||||||
|
she knows Naia, and filing means Kael's arrest and Naia's exposure.
|
||||||
|
Her silence IS the detective's investigation blocker in phase 1.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:sera-venn"
|
||||||
|
must_have_pattern: FRIEND
|
||||||
|
must_have_motivation: WITNESS
|
||||||
|
|
||||||
|
- role: ring-operative
|
||||||
|
display_hint: >
|
||||||
|
The ring's operational member in maintenance corridors.
|
||||||
|
Handles physical drops. Not a speaking character — observable behavior only.
|
||||||
|
Can be the anonymous contact Kael meets.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:nils-davan"
|
||||||
|
is_optional: false
|
||||||
|
|
||||||
|
- role: institutional-watcher
|
||||||
|
display_hint: >
|
||||||
|
Maret Korr. A Commission observer embedded at The Terminal.
|
||||||
|
Her growing attention is the external pressure that accelerates the timeline.
|
||||||
|
She doesn't know about the ring specifically — she's tracking cargo patterns.
|
||||||
|
binding: named
|
||||||
|
named_npc: "npc:maret-korr"
|
||||||
|
must_have_motivation: OPERATOR
|
||||||
|
is_optional: true # Module runs without Maret, but with degraded tension arc
|
||||||
|
|
||||||
|
# ── EVENTS ───────────────────────────────────────────────────────────────────
|
||||||
|
# Two sequences + one pool.
|
||||||
|
# Sequence A: Kael's exit arc (the FRIEND contradiction backbone)
|
||||||
|
# Sequence B: Investigation pressure arc (escalating discovery opportunities)
|
||||||
|
# Pool: ambient ring activity (fires opportunistically throughout the module)
|
||||||
|
|
||||||
|
events:
|
||||||
|
|
||||||
|
sequences:
|
||||||
|
|
||||||
|
# SEQUENCE A: Kael's Exit Arc
|
||||||
|
# The narrative spine. Each step makes Kael's contradiction more visible.
|
||||||
|
# Observable to both characters, interpreted differently.
|
||||||
|
|
||||||
|
- sequence_id: kael_exit_arc
|
||||||
|
label: "Kael's Exit Arc"
|
||||||
|
description: >
|
||||||
|
Kael Davan's progressive attempt to leave the ring.
|
||||||
|
Tells intensify. Routine deviations appear. The secret meeting is the
|
||||||
|
pivot point — after it fires, both characters' understanding shifts.
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- event_id: kael_goes_cold
|
||||||
|
label: "Kael Goes Cold"
|
||||||
|
description: >
|
||||||
|
Kael starts missing social patterns he'd normally keep — fewer bar visits,
|
||||||
|
shorter responses at The Terminal, leaving early. His tell system activates:
|
||||||
|
the shoulder-check behavior appears. Nothing dramatic. Just absence where
|
||||||
|
there was presence. The smuggler notices because they work together.
|
||||||
|
The detective might notice if they've been tracking Kael's baseline.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 300 # ~5 minutes after module activates
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael skips his usual post-shift drink at The Last Shift.
|
||||||
|
Leaves the terminal 15 minutes early. No explanation.
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael's shoulder-check behavior activates at The Terminal.
|
||||||
|
Visible to any character with forward vision cone in his direction.
|
||||||
|
sets_flag: kael_behavior_changed
|
||||||
|
|
||||||
|
- event_id: drop_happens_without_kael
|
||||||
|
label: "Scheduled Drop — Kael Absent"
|
||||||
|
description: >
|
||||||
|
A ring drop occurs in maintenance corridor C-7. Kael was supposed
|
||||||
|
to verify the cargo. He wasn't there. Nils covered it.
|
||||||
|
The smuggler notices the irregularity in the paperwork.
|
||||||
|
The detective — if watching cargo patterns — sees a manifest entry
|
||||||
|
with no verifying signature where one is normally present.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: kael_goes_cold
|
||||||
|
ticks: 450 # ~7.5 minutes after goes-cold
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.kael_missed_verification"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Smuggler: check the cargo manifest in The Terminal office.
|
||||||
|
Detective: analytical lattice flags unsigned verification entry.
|
||||||
|
- type: location_state
|
||||||
|
location: maintenance-corridors
|
||||||
|
description: "An unsigned cargo verification entry exists in corridor C-7's log."
|
||||||
|
sets_flag: kael_missed_drop
|
||||||
|
|
||||||
|
- event_id: kael_secret_meeting
|
||||||
|
label: "Kael's Secret Meeting"
|
||||||
|
description: >
|
||||||
|
Kael meets an off-district contact in maintenance corridor B-7.
|
||||||
|
This is the observable contradiction (D-034): Kael, in a restricted
|
||||||
|
area he has no logged reason to be in, talking to someone who's
|
||||||
|
not in any district NPC roster. His body language is tense.
|
||||||
|
If the player is in visual range: this is the pivot moment.
|
||||||
|
If not: the meeting happens anyway — the world doesn't wait.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: drop_happens_without_kael
|
||||||
|
ticks: 600 # ~10 minutes after the dropped verification
|
||||||
|
- type: player_proximity
|
||||||
|
target_type: location
|
||||||
|
target: maintenance-corridors
|
||||||
|
radius_tiles: 12 # Player wandering near triggers the meeting early
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael enters maintenance corridor B-7. Locked door to restricted
|
||||||
|
supply closet. Emerges with the ring-operative 8 minutes later.
|
||||||
|
Neither acknowledges the encounter publicly.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.kael_unauthorized_corridor_access"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Player must be in visual range of corridor B-7.
|
||||||
|
Or examine the corridor door access log (investigative action).
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
After the meeting, Kael's shoulder-check frequency doubles.
|
||||||
|
Also: he avoids eye contact with the smuggler at The Terminal.
|
||||||
|
sets_flag: secret_meeting_occurred
|
||||||
|
|
||||||
|
- event_id: kael_sends_message
|
||||||
|
label: "Kael Sends the Message"
|
||||||
|
description: >
|
||||||
|
Kael sends an encrypted Meridian message to an off-district contact.
|
||||||
|
The detective's analytical lattice can detect an anomalous outgoing
|
||||||
|
packet from the district node — not the content, just the pattern
|
||||||
|
(frequent, encrypted, sent from cargo bay terminals, not personal devices).
|
||||||
|
The smuggler won't see this unless they're specifically watching Kael.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: kael_secret_meeting
|
||||||
|
ticks: 200
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.kael_unusual_meridian_activity"
|
||||||
|
discoverable_by: detective
|
||||||
|
discovery_method: >
|
||||||
|
Detective's analytical lattice flags the outgoing packet pattern.
|
||||||
|
Requires player to be in or adjacent to The Terminal cargo bay.
|
||||||
|
sets_flag: kael_message_sent
|
||||||
|
|
||||||
|
- event_id: ring_leader_confronts_kael
|
||||||
|
label: "Voss Confronts Kael"
|
||||||
|
description: >
|
||||||
|
Voss calls Kael into The Terminal supervisor's office.
|
||||||
|
Closed door. Raised voices (audible only from adjacent room/position).
|
||||||
|
Kael emerges pale. Voss emerges neutral. The smuggler can witness
|
||||||
|
the approach/departure without hearing content. The detective can
|
||||||
|
observe Kael's state immediately after if in The Terminal.
|
||||||
|
This is Voss applying pressure. Kael is now visibly under strain.
|
||||||
|
triggers:
|
||||||
|
- type: flag_set
|
||||||
|
flag: kael_message_sent
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: kael_message_sent
|
||||||
|
ticks: 400
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss calls Kael into the supervisor's office. Door closed."
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael emerges from the meeting looking strained. His shoulder-check
|
||||||
|
is now constant. He takes an unscheduled break outside, alone.
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael's contentment hits lowest observed level. He now actively avoids
|
||||||
|
the ring-operative (Nils) in public. The disconnection is visible.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.voss_kael_private_meeting"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Observe the meeting room door (spatial). Or ask Kael directly
|
||||||
|
after (trust-gated dialogue unlocks "Are you alright?" option).
|
||||||
|
sets_flag: voss_pressure_applied
|
||||||
|
|
||||||
|
# SEQUENCE B: Investigation Pressure Arc
|
||||||
|
# External pressure that escalates the timeline.
|
||||||
|
# Fires in parallel with Sequence A.
|
||||||
|
|
||||||
|
- sequence_id: investigation_pressure
|
||||||
|
label: "Investigation Pressure Arc"
|
||||||
|
description: >
|
||||||
|
Maret Korr's institutional attention creates a closing window.
|
||||||
|
Her growing interest is the reason the module can't stay in equilibrium forever.
|
||||||
|
She doesn't know about the ring — she's a pattern-watcher. But patterns
|
||||||
|
are what the detective investigates too. Their paths converge.
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- event_id: maret_flags_anomaly
|
||||||
|
label: "Maret Flags the Cargo Anomaly"
|
||||||
|
description: >
|
||||||
|
Maret Korr files an internal Commission note flagging The Terminal's
|
||||||
|
cargo variance rate as statistically unusual. Not an investigation —
|
||||||
|
just a flag. The detective's institutional access can pull this note.
|
||||||
|
The smuggler has no way to know it exists (unless the detective tells them).
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 900 # ~15 minutes after activation
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.commission_cargo_flag"
|
||||||
|
discoverable_by: detective
|
||||||
|
discovery_method: >
|
||||||
|
Detective queries Commission data via institutional access
|
||||||
|
(authority access tier, Terminal records).
|
||||||
|
sets_flag: commission_flag_exists
|
||||||
|
|
||||||
|
- event_id: maret_increases_presence
|
||||||
|
label: "Maret Increases Her Presence"
|
||||||
|
description: >
|
||||||
|
Maret starts spending more time in The Terminal. More frequent
|
||||||
|
walkthroughs during shift changes. Her attention to the cargo bay
|
||||||
|
area is noticeable to anyone watching. Ring members are unnerved.
|
||||||
|
Voss starts accelerating the timeline to close operations before
|
||||||
|
institutional attention becomes formal investigation.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: maret_flags_anomaly
|
||||||
|
ticks: 600
|
||||||
|
- type: player_action
|
||||||
|
action: examine
|
||||||
|
target_role: institutional-watcher
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: institutional-watcher
|
||||||
|
description: >
|
||||||
|
Maret adds two extra Terminal walkthroughs per shift cycle.
|
||||||
|
Spends 15 minutes studying the cargo bay manifest terminals.
|
||||||
|
- type: tell_intensify
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: >
|
||||||
|
Voss becomes quieter, more deliberate. Less casual conversation.
|
||||||
|
His tell — the stillness before speaking — becomes more frequent.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.voss_accelerating_timeline"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Smuggler: Voss mentions "the schedule moving up" in a guarded
|
||||||
|
conversation (trust-gated, ring-insider access required).
|
||||||
|
Detective: observe Voss and Nils in two exchanges within same shift.
|
||||||
|
sets_flag: timeline_accelerating
|
||||||
|
|
||||||
|
- event_id: final_shipment_scheduled
|
||||||
|
label: "The Final Shipment Is Scheduled"
|
||||||
|
description: >
|
||||||
|
The ring schedules the last major drop — after this, they go dark.
|
||||||
|
This is the closing window. If the detective hasn't uncovered enough
|
||||||
|
by the time this fires, the ring disperses and the operation closes
|
||||||
|
without exposure (escaped outcome). If they have, confrontation
|
||||||
|
becomes unavoidable. The smuggler knows about this drop. Kael doesn't
|
||||||
|
want to participate. Voss insists.
|
||||||
|
triggers:
|
||||||
|
- type: flag_set
|
||||||
|
flag: timeline_accelerating
|
||||||
|
- type: ticks_since_event
|
||||||
|
after_event: maret_increases_presence
|
||||||
|
ticks: 800
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.final_shipment_scheduled"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Smuggler: direct notification from Voss.
|
||||||
|
Detective: cargo manifest shows an unusual large entry for 3 days out.
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: >
|
||||||
|
Kael's schedule changes: he's assigned to the cargo bay
|
||||||
|
during the drop window. He doesn't want to be there.
|
||||||
|
sets_flag: final_shipment_known
|
||||||
|
|
||||||
|
pools:
|
||||||
|
|
||||||
|
# POOL: Ambient ring activity — opportunistic events that add texture
|
||||||
|
- pool_id: ambient_ring_activity
|
||||||
|
label: "Ambient Ring Activity"
|
||||||
|
description: >
|
||||||
|
Low-level ring business that happens throughout the module regardless of
|
||||||
|
player engagement. Creates the sense that the ring exists independently.
|
||||||
|
Players who look closely will find more; players who don't still feel the world moving.
|
||||||
|
events:
|
||||||
|
- event_id: cargo_discrepancy_appears
|
||||||
|
label: "Small Cargo Discrepancy Appears"
|
||||||
|
description: >
|
||||||
|
A minor manifest irregularity appears in The Terminal records.
|
||||||
|
Small enough to be deniable. Systematic enough to be a pattern.
|
||||||
|
The detective's analytical lattice may flag it. The smuggler can
|
||||||
|
correct it if they notice it — covering tracks is part of their role.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 150 # Fires early and repeats
|
||||||
|
effects:
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.cargo_discrepancy_pattern"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Detective: analytical lattice flags during Terminal walkthrough.
|
||||||
|
Smuggler: check manifest terminals (or get flagged by the discrepancy
|
||||||
|
in their own work).
|
||||||
|
once: false # Repeats — pattern builds over time
|
||||||
|
|
||||||
|
- event_id: sera_avoids_torek
|
||||||
|
label: "Sera Avoids Torek at The Bar"
|
||||||
|
description: >
|
||||||
|
Sera Venn reroutes her usual path through The Last Shift to avoid
|
||||||
|
standing near Torek Lintar (the Commission enforcement officer).
|
||||||
|
Anyone watching Sera's normal pattern would notice.
|
||||||
|
This is the detective's first clue that Sera's behavior is odd.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 500
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: evidence-holder
|
||||||
|
description: >
|
||||||
|
Sera takes a longer route to her usual seat, passing through
|
||||||
|
the back of the bar to avoid Torek's sightline.
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.sera_avoidance_behavior"
|
||||||
|
discoverable_by: detective
|
||||||
|
discovery_method: >
|
||||||
|
Observe Sera's path through the bar on two separate occasions.
|
||||||
|
Requires forward vision cone and awareness of her baseline route.
|
||||||
|
once: false
|
||||||
|
|
||||||
|
- event_id: nils_makes_supply_run
|
||||||
|
label: "Nils Makes an Unscheduled Supply Run"
|
||||||
|
description: >
|
||||||
|
The ring-operative (Nils) enters the maintenance corridors with a
|
||||||
|
small container logged as "calibration tools". The container isn't
|
||||||
|
logged for return. Someone paying attention to cargo flow would notice.
|
||||||
|
triggers:
|
||||||
|
- type: ticks_since_activation
|
||||||
|
ticks: 700
|
||||||
|
effects:
|
||||||
|
- type: npc_routine_deviation
|
||||||
|
npc_role: ring-operative
|
||||||
|
description: "Nils takes a container to maintenance corridor C-7."
|
||||||
|
- type: fact_becomes_discoverable
|
||||||
|
fact_id: "ring.nils_unlogged_cargo"
|
||||||
|
discoverable_by: any
|
||||||
|
discovery_method: >
|
||||||
|
Watch Nils's cargo handling pattern over two shifts.
|
||||||
|
Or examine maintenance corridor C-7 access log.
|
||||||
|
once: false
|
||||||
|
|
||||||
|
# ── OUTCOMES ─────────────────────────────────────────────────────────────────
|
||||||
|
# Five resolution states. Checked each tick after the first sequence step fires.
|
||||||
|
# Order matters — the storyteller applies the first matching outcome.
|
||||||
|
# is_terminal: true ends the module.
|
||||||
|
|
||||||
|
outcomes:
|
||||||
|
|
||||||
|
# 1. RING EXPOSED
|
||||||
|
# Detective successfully uncovers the operation.
|
||||||
|
# Commission becomes involved. Arrests/flight follow.
|
||||||
|
- outcome_id: ring_exposed
|
||||||
|
label: "Ring Exposed"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
The detective accumulates enough evidence to trigger a formal Commission
|
||||||
|
inquiry. The ring collapses: arrests, flight, or both. Voss is detained.
|
||||||
|
Kael's situation is now public. The smuggler (if played) faces consequences.
|
||||||
|
Naia learns what Kael was doing — and why he was trying to leave.
|
||||||
|
No clean endings. The right outcome for the detective who goes all the way.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern"
|
||||||
|
- "ring.kael_unauthorized_corridor_access"
|
||||||
|
- "ring.voss_kael_private_meeting"
|
||||||
|
flags_set:
|
||||||
|
- secret_meeting_occurred # set by kael_secret_meeting event
|
||||||
|
- commission_flag_exists # Commission was watching before exposure
|
||||||
|
effects:
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-leader
|
||||||
|
shift: hostile
|
||||||
|
description: "Voss is detained or flees. Commission inquiry opens."
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
shift: hostile
|
||||||
|
description: >
|
||||||
|
Kael is arrested or disappears. His exit attempt is now moot.
|
||||||
|
His relationship with Naia is exposed.
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: lattice-commission
|
||||||
|
reaction: grateful
|
||||||
|
description: "Commission credits the detective's investigation."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss leaves the district — detained, fled, or both."
|
||||||
|
|
||||||
|
# 2. KAEL ESCAPES THE RING
|
||||||
|
# Unique path. Requires the player to engage with Kael directly
|
||||||
|
# and choose to help him rather than expose the ring wholesale.
|
||||||
|
- outcome_id: kael_escapes
|
||||||
|
label: "Kael Escapes the Ring"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
Through the player's choices — helping Kael cover his exit, or warning him,
|
||||||
|
or simply choosing not to act on what they know — Kael successfully leaves
|
||||||
|
the ring before the final shipment. He and Naia leave the district quietly.
|
||||||
|
The ring continues without him, smaller and more cautious.
|
||||||
|
This outcome requires discovering Kael's secret AND choosing restraint.
|
||||||
|
The smuggler can engineer this by covering for Kael with Voss.
|
||||||
|
The detective can achieve this by confronting Kael privately rather than
|
||||||
|
filing a report. The most morally complicated path.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.kael_unauthorized_corridor_access"
|
||||||
|
flags_set:
|
||||||
|
- kael_behavior_changed # set by kael_goes_cold — his exit arc begins here
|
||||||
|
- secret_meeting_occurred # set by kael_secret_meeting — the pivot moment
|
||||||
|
- voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied
|
||||||
|
# ring_exposed is checked first in the outcomes list and is terminal,
|
||||||
|
# so kael_escapes only evaluates if ring_exposed hasn't fired.
|
||||||
|
# No flags_not_set needed here — outcome ordering handles priority.
|
||||||
|
effects:
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
shift: friendly
|
||||||
|
description: "Kael remembers whoever helped him. He's gone, but grateful."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-member-exiting
|
||||||
|
description: "Kael and Naia leave Sova Transit District."
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: suspicious
|
||||||
|
description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks."
|
||||||
|
|
||||||
|
# 3. RING COMPLETES OPERATION
|
||||||
|
# The ring finishes the final shipment and goes dark before discovery.
|
||||||
|
# Default path if the detective doesn't move fast enough.
|
||||||
|
- outcome_id: ring_completes
|
||||||
|
label: "Ring Completes the Operation"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet.
|
||||||
|
Kael stays — he's now out by default, the ring having dissolved around him.
|
||||||
|
The evidence trail goes cold. The detective closes the case as inconclusive.
|
||||||
|
The smuggler completes their last run and waits to see if there's another.
|
||||||
|
Unsatisfying only if you expected a tidy resolution. The world moved on.
|
||||||
|
conditions:
|
||||||
|
flags_set:
|
||||||
|
- final_shipment_known # set by final_shipment_scheduled event
|
||||||
|
- timeline_accelerating # set by maret_increases_presence — Maret forced their hand
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation
|
||||||
|
ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure
|
||||||
|
# kael_message_sent was previously gated here but auto-fires at tick ~1550,
|
||||||
|
# making this outcome permanently unreachable. Replaced with player-action fact gate.
|
||||||
|
effects:
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: neutral
|
||||||
|
description: "The ring successfully completed this operation. They'll be back."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss transfers to another station for 'career development'."
|
||||||
|
- type: location_access_change
|
||||||
|
location: maintenance-corridors
|
||||||
|
change: open
|
||||||
|
description: "The restricted supply closet is now empty. Access log shows it cleared."
|
||||||
|
|
||||||
|
# 4. RING SPLINTERS
|
||||||
|
# Partial discovery. The ring fractures but doesn't fully collapse.
|
||||||
|
# An incomplete ending that leaves threads for future investigation.
|
||||||
|
- outcome_id: ring_splinters
|
||||||
|
label: "Ring Splinters"
|
||||||
|
is_terminal: false # Not terminal — splinter state can evolve
|
||||||
|
description: >
|
||||||
|
Enough evidence surfaces that the ring knows it's been partially seen.
|
||||||
|
Voss shuts down active operations. Nils disappears. Kael stays — now the
|
||||||
|
one person in the district who knows what happened and has no one to tell.
|
||||||
|
The formal investigation stalls for lack of a clear chain of evidence.
|
||||||
|
The detective has facts but not the complete picture. The smuggler
|
||||||
|
faces an awkward return to normalcy. Both know the ring isn't gone — just quiet.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds
|
||||||
|
events_fired:
|
||||||
|
- kael_goes_cold # event ID — Kael's behavioral shift fired
|
||||||
|
flags_set:
|
||||||
|
- kael_missed_drop # set by drop_happens_without_kael — ring destabilized
|
||||||
|
ticks_since_activation: 2400
|
||||||
|
# Mutually exclusive with ring_completes via facts_known/facts_not_known on
|
||||||
|
# ring.cargo_discrepancy_pattern. No auto-flag gate needed.
|
||||||
|
effects:
|
||||||
|
- type: npc_disposition
|
||||||
|
npc_role: ring-leader
|
||||||
|
shift: suspicious
|
||||||
|
description: "Voss goes quiet. He's watching to see who knows what."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-operative
|
||||||
|
description: "Nils stops appearing at The Terminal. Transferred, officially."
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: suspicious
|
||||||
|
description: "The ring is alerted to exposure risk. Future operations will be more careful."
|
||||||
|
|
||||||
|
# 5. INVESTIGATION STALLS (post-splinter exit)
|
||||||
|
# The ring splinters but the detective never breaks through to the pivot evidence.
|
||||||
|
# Explicit terminal exit for the non-terminal ring_splinters state.
|
||||||
|
- outcome_id: ring_stalemate
|
||||||
|
label: "Investigation Stalls"
|
||||||
|
is_terminal: true
|
||||||
|
description: >
|
||||||
|
The ring went dark after the splinter. The detective has the cargo discrepancy
|
||||||
|
on record — enough to flag, not enough to pursue. The case stays open but cold.
|
||||||
|
No arrests. No answers. Kael stays in the district, the only person who knows
|
||||||
|
the full shape of what happened, with no one left to tell it to.
|
||||||
|
The ring will reconstitute elsewhere. It always does.
|
||||||
|
conditions:
|
||||||
|
facts_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate)
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence
|
||||||
|
flags_set:
|
||||||
|
- kael_missed_drop
|
||||||
|
- final_shipment_known # ring finished while investigation stalled
|
||||||
|
ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold
|
||||||
|
effects:
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: lattice-commission
|
||||||
|
reaction: neutral
|
||||||
|
description: "The discrepancy flag stays in Maret's file. No follow-up action."
|
||||||
|
- type: npc_exit
|
||||||
|
npc_role: ring-leader
|
||||||
|
description: "Voss quietly transfers. No announcement, no incident report."
|
||||||
|
|
||||||
|
# 6. MODULE EXPIRY (quiet exit)
|
||||||
|
# Player never engaged at all. Module times out without drama.
|
||||||
|
# NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set.
|
||||||
|
# kael_behavior_changed fires automatically at tick 300 (time-triggered), making
|
||||||
|
# flags_not_set: [kael_behavior_changed] permanently false after tick 300.
|
||||||
|
# Gate expiry on player-action-required facts instead.
|
||||||
|
- outcome_id: module_abandoned
|
||||||
|
label: "Module Abandoned"
|
||||||
|
is_terminal: true
|
||||||
|
is_expiry: true
|
||||||
|
description: >
|
||||||
|
The player never engaged with the ring's signals. The final shipment
|
||||||
|
completed without incident. The ring disperses on its own schedule.
|
||||||
|
Kael stays. The world is unchanged. This is not failure — it's the game
|
||||||
|
acknowledging that not every conspiracy needs a protagonist.
|
||||||
|
The 70% mundane majority (D-029) plays out: life continued.
|
||||||
|
conditions:
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal
|
||||||
|
- "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7
|
||||||
|
ticks_since_activation: 5400 # ~90 minutes with zero player investigation
|
||||||
|
effects:
|
||||||
|
- type: faction_reaction
|
||||||
|
faction: the-ring
|
||||||
|
reaction: neutral
|
||||||
|
description: "The ring closed operations without incident. No record of compromise."
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
# Drama Module Schema — Tier 1 Content (D-023)
|
||||||
|
# YAML expression of JSON Schema 2020-12
|
||||||
|
# Validated against this schema: content/modules/tier1/*.yaml
|
||||||
|
#
|
||||||
|
# Ownership:
|
||||||
|
# Dramatic structure (this file): Paula
|
||||||
|
# YAML validation tooling / serde structs: Gestalt / Tyre
|
||||||
|
# Authoring ergonomics review: Mellanie
|
||||||
|
#
|
||||||
|
# See: docs/design/tier1-module-authoring.md for field-by-field guide.
|
||||||
|
|
||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||||
|
$id: "drama_module.schema.yaml"
|
||||||
|
title: "Tier 1 Drama Module"
|
||||||
|
description: >
|
||||||
|
A hand-authored drama module drawn from the pool at game start.
|
||||||
|
The storyteller activates one or more modules per playthrough based on
|
||||||
|
entry conditions, then fires events and detects outcomes. Tier 1 modules
|
||||||
|
are the conspiracy layer of D-023 — authored, optional, relocatable.
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- module_id
|
||||||
|
- display_name
|
||||||
|
- version
|
||||||
|
- tier
|
||||||
|
- pool
|
||||||
|
- entry_conditions
|
||||||
|
- npc_requirements
|
||||||
|
- events
|
||||||
|
- outcomes
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
properties:
|
||||||
|
|
||||||
|
# ── IDENTITY ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
module_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||||
|
description: >
|
||||||
|
Stable unique slug. Format: {name}_v{major}_{minor}.
|
||||||
|
Never reuse IDs. Increment version on breaking structural changes.
|
||||||
|
Example: "smuggling_ring_v0_1"
|
||||||
|
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: "Human-readable title shown in dev/debug tooling."
|
||||||
|
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
pattern: "^[0-9]+\\.[0-9]+$"
|
||||||
|
description: "Authoring version. Semantic: major.minor."
|
||||||
|
|
||||||
|
tier:
|
||||||
|
type: integer
|
||||||
|
const: 1
|
||||||
|
description: "Always 1 for Tier 1 drama modules."
|
||||||
|
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: "One-paragraph authoring summary. Not shown in-game."
|
||||||
|
|
||||||
|
# ── POOL METADATA ─────────────────────────────────────────────────────────
|
||||||
|
# Controls how the storyteller includes this module in the per-playthrough pool.
|
||||||
|
|
||||||
|
pool:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- weight
|
||||||
|
additionalProperties: false
|
||||||
|
description: "How the storyteller samples this module from the pool."
|
||||||
|
properties:
|
||||||
|
weight:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 10
|
||||||
|
description: >
|
||||||
|
Relative selection probability (1–10). Higher = more likely to be
|
||||||
|
included in a given playthrough's active module set. Default: 5.
|
||||||
|
compatible_districts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
District slugs where this module can activate, or omit for "any".
|
||||||
|
Example: ["sova-transit"]
|
||||||
|
incompatible_with:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||||
|
description: >
|
||||||
|
Module IDs that cannot run concurrently with this one.
|
||||||
|
The storyteller will not activate both in the same playthrough.
|
||||||
|
max_concurrent:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
description: >
|
||||||
|
Maximum simultaneous active instances. Almost always 1.
|
||||||
|
Set to 2+ only for modules designed to stack (rare).
|
||||||
|
|
||||||
|
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
|
||||||
|
# All listed conditions must be true for the module to become activatable.
|
||||||
|
# The storyteller checks these each tick after min_play_ticks.
|
||||||
|
|
||||||
|
entry_conditions:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- activation
|
||||||
|
additionalProperties: false
|
||||||
|
description: >
|
||||||
|
World-state prerequisites. The storyteller activates the module when
|
||||||
|
ALL conditions are satisfied AND the activation trigger fires.
|
||||||
|
properties:
|
||||||
|
world_state:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/world_state_condition"
|
||||||
|
description: "World-state conditions checked each tick."
|
||||||
|
player:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/player_condition"
|
||||||
|
description: >
|
||||||
|
Optional player-state conditions. Module can activate without
|
||||||
|
player engagement — these gate on player-specific world state,
|
||||||
|
not on player noticing the module.
|
||||||
|
activation:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- trigger
|
||||||
|
additionalProperties: false
|
||||||
|
description: "How and when activation is evaluated."
|
||||||
|
properties:
|
||||||
|
trigger:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- proximity # Player comes within range of a key NPC/location
|
||||||
|
- storyteller_push # Storyteller activates on its own schedule
|
||||||
|
- player_action # Player performs a specific action
|
||||||
|
description: "What pushes the module from 'eligible' to 'active'."
|
||||||
|
min_play_ticks:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: >
|
||||||
|
Minimum ticks of game time before this module can activate.
|
||||||
|
Enforces D-027 success criterion #1: 30 minutes of daily-life
|
||||||
|
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
|
||||||
|
proximity_location:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Required when trigger = proximity. Location slug the player
|
||||||
|
must enter or approach. Example: "maintenance-corridors"
|
||||||
|
proximity_radius_tiles:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
description: >
|
||||||
|
Required when trigger = proximity. Tile radius around the
|
||||||
|
location's anchor point.
|
||||||
|
player_action_required:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Required when trigger = player_action. The action that fires
|
||||||
|
activation. Example: "examine:cargo-manifest"
|
||||||
|
|
||||||
|
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
|
||||||
|
# NPC slots this module requires. Each slot is filled at module load time.
|
||||||
|
# Named bindings resolve to specific authored NPCs; generated bindings
|
||||||
|
# are filled from the district's generated NPC pool.
|
||||||
|
|
||||||
|
npc_requirements:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/npc_slot"
|
||||||
|
description: >
|
||||||
|
Module-internal NPC role slots. Roles are referenced by slug throughout
|
||||||
|
the rest of this document. Hand-authored NPCs use named bindings.
|
||||||
|
Generated NPCs use constraint-based bindings.
|
||||||
|
|
||||||
|
# ── EVENTS ────────────────────────────────────────────────────────────────
|
||||||
|
# Ordered sequences and unordered event pools the storyteller can fire.
|
||||||
|
# Sequences are narrative beats in a defined order.
|
||||||
|
# Pools are events the storyteller can fire in any order when conditions are met.
|
||||||
|
|
||||||
|
events:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: "Event sequences and pools the storyteller manages."
|
||||||
|
properties:
|
||||||
|
sequences:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_sequence"
|
||||||
|
description: >
|
||||||
|
Ordered event sequences. Steps fire in order; the next step
|
||||||
|
becomes eligible only after the previous one fires.
|
||||||
|
pools:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_pool"
|
||||||
|
description: >
|
||||||
|
Unordered event pools. The storyteller may fire any eligible
|
||||||
|
event in the pool when its trigger conditions are met.
|
||||||
|
|
||||||
|
# ── OUTCOMES ──────────────────────────────────────────────────────────────
|
||||||
|
# Resolution states the module can reach. The storyteller checks outcome
|
||||||
|
# conditions each tick. First matching outcome wins.
|
||||||
|
# Every module MUST include an expiry outcome.
|
||||||
|
|
||||||
|
outcomes:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/outcome"
|
||||||
|
description: >
|
||||||
|
Terminal and transitional resolution states. The storyteller checks
|
||||||
|
these each tick and applies the first matching outcome.
|
||||||
|
|
||||||
|
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
|
||||||
|
|
||||||
|
dual_lens:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: "Authoring-only. How smuggler vs detective experience this module."
|
||||||
|
properties:
|
||||||
|
smuggler: { type: string }
|
||||||
|
detective: { type: string }
|
||||||
|
|
||||||
|
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$defs:
|
||||||
|
|
||||||
|
# World-state condition types
|
||||||
|
|
||||||
|
world_state_condition:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A single world-state prerequisite for module activation."
|
||||||
|
oneOf:
|
||||||
|
- # NPC with the given module role is present in the district
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_present" }
|
||||||
|
role: { type: string, description: "Module-internal NPC role slug." }
|
||||||
|
required: [type, role]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A specific location is accessible to the player
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "location_accessible" }
|
||||||
|
location: { type: string, description: "Location slug." }
|
||||||
|
required: [type, location]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player has NOT yet discovered a specific fact
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_not_known" }
|
||||||
|
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
|
||||||
|
required: [type, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # No other Tier 1 module of the given ID is currently active
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "no_active_module" }
|
||||||
|
module_id: { type: string }
|
||||||
|
required: [type, module_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A named fact IS known (module requires precondition awareness)
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_known" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
known_by: { type: string, enum: [smuggler, detective, any] }
|
||||||
|
required: [type, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# Player-state condition types
|
||||||
|
|
||||||
|
player_condition:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A player-state prerequisite."
|
||||||
|
oneOf:
|
||||||
|
- # Player has reached minimum relationship threshold with an NPC
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "relationship_threshold" }
|
||||||
|
npc_role: { type: string, description: "Module-internal NPC role." }
|
||||||
|
min_state:
|
||||||
|
type: string
|
||||||
|
enum: [stranger, known, friendly]
|
||||||
|
description: "Minimum RelationshipState required."
|
||||||
|
required: [type, npc_role, min_state]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Minimum game ticks elapsed
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "min_ticks" }
|
||||||
|
ticks: { type: integer, minimum: 0 }
|
||||||
|
required: [type, ticks]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# NPC slot definition
|
||||||
|
|
||||||
|
npc_slot:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- role
|
||||||
|
- binding
|
||||||
|
additionalProperties: false
|
||||||
|
description: >
|
||||||
|
One NPC slot in the module. Named binding = specific authored NPC.
|
||||||
|
Generated binding = constraint-matched NPC from district pool.
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9-]*$"
|
||||||
|
description: >
|
||||||
|
Module-internal role slug. Referenced in events, outcomes, and
|
||||||
|
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
|
||||||
|
display_hint:
|
||||||
|
type: string
|
||||||
|
description: "Authoring note. What this role is narratively."
|
||||||
|
binding:
|
||||||
|
type: string
|
||||||
|
enum: [named, generated]
|
||||||
|
description: >
|
||||||
|
named = resolves to a specific authored NPC (use named_npc).
|
||||||
|
generated = any district NPC matching the axis constraints.
|
||||||
|
named_npc:
|
||||||
|
type: string
|
||||||
|
pattern: "^npc:[a-z][a-z0-9-]*$"
|
||||||
|
description: >
|
||||||
|
Required when binding = named. Short-form NPC canonical ID.
|
||||||
|
Example: "npc:kael-davan"
|
||||||
|
axes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/axis_constraint"
|
||||||
|
description: >
|
||||||
|
Required when binding = generated. The NPC must satisfy all
|
||||||
|
listed axis constraints to fill this slot.
|
||||||
|
must_have_pattern:
|
||||||
|
type: string
|
||||||
|
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
|
||||||
|
description: "Optional: NPC must have this pattern (D-024)."
|
||||||
|
must_have_motivation:
|
||||||
|
type: string
|
||||||
|
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
|
||||||
|
description: "Optional: NPC must have this motivation (D-024)."
|
||||||
|
is_optional:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: >
|
||||||
|
If true, the module can activate without this slot filled.
|
||||||
|
Optional slots produce degraded but valid module runs.
|
||||||
|
|
||||||
|
# NPC axis constraint (used in generated bindings)
|
||||||
|
|
||||||
|
axis_constraint:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- axis
|
||||||
|
- constraint
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
axis:
|
||||||
|
type: string
|
||||||
|
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
|
||||||
|
description: "Which NPC axis to constrain (D-024)."
|
||||||
|
constraint:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Constraint expression. Freeform string interpreted by the storyteller.
|
||||||
|
Convention: "has_{value}", "min_{N}", "not_{value}".
|
||||||
|
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
|
||||||
|
|
||||||
|
# Event sequence
|
||||||
|
|
||||||
|
event_sequence:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- sequence_id
|
||||||
|
- steps
|
||||||
|
additionalProperties: false
|
||||||
|
description: "An ordered sequence of narrative events."
|
||||||
|
properties:
|
||||||
|
sequence_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
steps:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_step"
|
||||||
|
|
||||||
|
# Unordered event pool
|
||||||
|
|
||||||
|
event_pool:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- pool_id
|
||||||
|
- events
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
pool_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_step"
|
||||||
|
|
||||||
|
# Individual event step
|
||||||
|
|
||||||
|
event_step:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- event_id
|
||||||
|
- triggers
|
||||||
|
additionalProperties: false
|
||||||
|
description: "A single storyteller-managed event with triggers and effects."
|
||||||
|
properties:
|
||||||
|
event_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
description: "Unique within this module. Used in outcome conditions."
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: "What happens narratively when this event fires."
|
||||||
|
triggers:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_trigger"
|
||||||
|
description: "ANY trigger being true fires this event."
|
||||||
|
effects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/event_effect"
|
||||||
|
description: "What changes in the world when this event fires."
|
||||||
|
once:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
description: "If true, fires only once. If false, may repeat when conditions reset."
|
||||||
|
sets_flag:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
description: "Module-internal flag set when this event fires. Queryable in outcomes."
|
||||||
|
|
||||||
|
# Event trigger conditions
|
||||||
|
|
||||||
|
event_trigger:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A condition that causes an event to fire."
|
||||||
|
oneOf:
|
||||||
|
- # Ticks elapsed since module activation
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "ticks_since_activation" }
|
||||||
|
ticks: { type: integer, minimum: 1 }
|
||||||
|
required: [type, ticks]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Ticks elapsed since a previous event fired
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "ticks_since_event" }
|
||||||
|
after_event: { type: string }
|
||||||
|
ticks: { type: integer, minimum: 1 }
|
||||||
|
required: [type, after_event, ticks]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player enters a location or comes within range of NPC
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "player_proximity" }
|
||||||
|
target_type: { type: string, enum: [location, npc_role] }
|
||||||
|
target: { type: string }
|
||||||
|
radius_tiles: { type: integer, minimum: 1 }
|
||||||
|
required: [type, target_type, target]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player performs an interaction
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "player_action" }
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [talk, examine, confront, follow, observe]
|
||||||
|
target_role: { type: string, description: "Module NPC role or location slug." }
|
||||||
|
required: [type, action, target_role]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Player has discovered a specific fact
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_known_by_player" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
required: [type, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A module flag has been set
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "flag_set" }
|
||||||
|
flag: { type: string }
|
||||||
|
required: [type, flag]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC enters a specific mood state
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_mood" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
mood:
|
||||||
|
type: string
|
||||||
|
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
|
||||||
|
required: [type, npc_role, mood]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# Event effects
|
||||||
|
|
||||||
|
event_effect:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
description: "A world change triggered by an event."
|
||||||
|
oneOf:
|
||||||
|
- # NPC deviates from their normal routine
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_routine_deviation" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
description: { type: string, description: "What the deviation looks like." }
|
||||||
|
duration_ticks: { type: integer }
|
||||||
|
required: [type, npc_role, description]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A fact becomes discoverable (moves to Rumoured confidence)
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_becomes_discoverable" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
discoverable_by:
|
||||||
|
type: string
|
||||||
|
enum: [smuggler, detective, any]
|
||||||
|
discovery_method:
|
||||||
|
type: string
|
||||||
|
description: "How the player can discover this. Authoring note."
|
||||||
|
required: [type, fact_id, discoverable_by]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC tell behavior becomes more pronounced
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "tell_intensify" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A module-internal flag is set
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "flag_set" }
|
||||||
|
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
|
||||||
|
required: [type, flag]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Something changes about a location
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "location_state" }
|
||||||
|
location: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, location, description]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC's access to information changes
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_knowledge_update" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
fact_id: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role, fact_id]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
# Module outcome definition
|
||||||
|
|
||||||
|
outcome:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- outcome_id
|
||||||
|
- label
|
||||||
|
- is_terminal
|
||||||
|
additionalProperties: false
|
||||||
|
description: >
|
||||||
|
A resolution state the module can reach. Conditions are checked each tick.
|
||||||
|
The first matching outcome is applied. is_terminal = true ends the module.
|
||||||
|
properties:
|
||||||
|
outcome_id:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_-]*$"
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: "What this outcome means narratively."
|
||||||
|
is_terminal:
|
||||||
|
type: boolean
|
||||||
|
description: "If true, this outcome ends the module permanently."
|
||||||
|
is_expiry:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: >
|
||||||
|
If true, this is the quiet-exit outcome when the player never engages.
|
||||||
|
Every module must include exactly one expiry outcome.
|
||||||
|
conditions:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
description: "ALL conditions must be true to reach this outcome."
|
||||||
|
properties:
|
||||||
|
facts_known:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "Player must know all these facts."
|
||||||
|
facts_not_known:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "Player must NOT know any of these facts."
|
||||||
|
flags_set:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "All these module flags must be set."
|
||||||
|
flags_not_set:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "None of these module flags may be set."
|
||||||
|
events_fired:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: "All these events must have fired."
|
||||||
|
ticks_since_activation:
|
||||||
|
type: integer
|
||||||
|
description: "Module has been active for at least this many ticks."
|
||||||
|
effects:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/outcome_effect"
|
||||||
|
description: "Effects applied when this outcome is reached."
|
||||||
|
|
||||||
|
# Outcome-level effects (broader scope than event effects)
|
||||||
|
|
||||||
|
outcome_effect:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
oneOf:
|
||||||
|
- # NPC disposition toward player changes
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_disposition" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
shift:
|
||||||
|
type: string
|
||||||
|
enum: [hostile, suspicious, neutral, friendly]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role, shift]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Faction reaction
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "faction_reaction" }
|
||||||
|
faction: { type: string }
|
||||||
|
reaction:
|
||||||
|
type: string
|
||||||
|
enum: [hostile, suspicious, neutral, friendly, grateful]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, faction, reaction]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # Location becomes restricted or opens up
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "location_access_change" }
|
||||||
|
location: { type: string }
|
||||||
|
change:
|
||||||
|
type: string
|
||||||
|
enum: [restricted, locked, open]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, location, change]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # A fact is now permanently known/unknown
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "fact_state" }
|
||||||
|
fact_id: { type: string }
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
enum: [known, hidden, destroyed]
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, fact_id, state]
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
- # NPC leaves the district or changes role
|
||||||
|
properties:
|
||||||
|
type: { type: string, const: "npc_exit" }
|
||||||
|
npc_role: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
required: [type, npc_role]
|
||||||
|
additionalProperties: false
|
||||||
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()
|
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
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
HELP_TEXT = """\
|
HELP_TEXT = """\
|
||||||
Commonwealth Decisions Sync
|
Commonwealth Decisions Sync & ID Management
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
decisions_sync.py sync Parse decisions/*.md and upsert into SQLite
|
||||||
decisions_sync.py --help Show this help message
|
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
|
ID claiming workflow:
|
||||||
decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions
|
1. Agent calls 'claim D architecture "Per-game save dirs"'
|
||||||
and decision_refs tables.
|
2. Gets back D-085 (or whatever is next)
|
||||||
|
3. Agent writes D-085 in the appropriate domain file
|
||||||
Idempotent: safe to run repeatedly. References are rebuilt on every sync.
|
4. Pre-commit hook runs check-dupes to catch collisions
|
||||||
|
|
||||||
Config: {config}
|
Config: {config}
|
||||||
Schema: {schema}
|
Schema: {schema}
|
||||||
@@ -404,6 +515,18 @@ def main():
|
|||||||
|
|
||||||
if cmd == "sync":
|
if cmd == "sync":
|
||||||
result = sync(cfg)
|
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:
|
else:
|
||||||
result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."}
|
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 |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
||||||
| [questions.md](questions.md) | Open questions | Q-001 through Q-026 |
|
| [questions.md](questions.md) | Open questions | Q-001 through Q-039 |
|
||||||
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
|
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
|
||||||
|
|
||||||
## Querying Decisions
|
## Querying Decisions
|
||||||
|
|||||||
@@ -210,4 +210,39 @@ 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-094: District Spatial Hierarchy — Chunk, Block, District Naming and Sizes
|
||||||
|
- **Date:** 2026-02-25
|
||||||
|
- **Decision:** The spatial hierarchy for map generation and streaming is defined as follows. **Chunk** = 64×64 sim tiles (32×32 visual tiles, 32m) — the streaming and serialization unit. **Block** = 128×128 sim tiles (64×64 visual tiles, 64m) — the generator planning unit, composed of 4 chunks arranged in a 2×2 grid. Each block contains 4 chunks; chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes), or form L-shaped buildings across chunk boundaries. **District** = 4×4 blocks = 512×512 sim tiles (256×256 visual tiles, 256m) per z-level, containing 16 blocks and 64 chunks. Large civic structures (gate terminals, horizon station installations, stadiums, parks) span multiple blocks. Three z-levels for the Transit District = ~1.35MB (trivial). This decision amends D-012 and overrides the ~150×150 visual estimate in D-014.
|
||||||
|
- **Rationale:** Chunk size of 32×32 visual (64×64 sim) gives a 32m streaming cell — large enough to hold a meaningful space, small enough for efficient streaming. The 2×2-chunk block provides a generator planning unit with enough granularity for per-chunk variation. The 4×4 block district (256×256 visual) gives a full district footprint generalisable as a template for the Q-036 generator. The chunk-based fill system within blocks allows the generator to place buildings of varying scale without hard-coding building dimensions.
|
||||||
|
- **Raised by:** Tyre (chunk/block spec and memory confirmation), confirmed by team. Lead ratified district = 4×4 blocks.
|
||||||
|
- **Dissent:** Araminta preferred 32×32 visual chunk size (effectively halving the chunk to a 16m cell). Overruled by lead and team majority — 32m chunk is the minimum viable streaming cell for the simulation architecture.
|
||||||
|
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
|
||||||
|
- **Cross-reference:** D-012 (tile spec — amended), D-014 (v0.1 map spec — district bounding box superseded), D-066 (dual-scale grid), D-093 (Sova Transit District layout using this hierarchy), Q-036 (district generator)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*19 decisions. Last updated: 2026-02-25 (D-094 added — Station District Layout Workshop, Sprint 20)*
|
||||||
|
|||||||
+66
-1
@@ -182,6 +182,71 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
|||||||
- **Raised by:** Tyre (technical analysis, architecture synthesis)
|
- **Raised by:** Tyre (technical analysis, architecture synthesis)
|
||||||
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
|
- **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.*
|
||||||
|
|
||||||
|
### D-084: Dual-namespace line ID scheme — role pool + instance override
|
||||||
|
- **Date:** 2026-02-25
|
||||||
|
- **Decision:** Line IDs for auto-generated NPC content use a dual-namespace approach that eliminates the Q-028 collision problem without altering the existing ID format.
|
||||||
|
- **Role namespace (unchanged, primary):** `{role-slug}_{d|m|e}_{###}` — e.g., `dock-worker_d_001`. These are shared lines delivered by any instance of the role. One file, one ID sequence per role-at-location. No per-instance authoring, no collision possible. All existing authored content is unaffected.
|
||||||
|
- **Instance namespace (new, opt-in):** `{role-slug}-{zero-padded counter}_{d|m|e}_{###}` — e.g., `dock-worker-07_d_001`. Counter is the generation-order rank within the role group for that district, starting at 01, assigned deterministically from the world seed. Used only when a specific generated NPC needs authored content that differs from the role pool.
|
||||||
|
- **Key design choices:**
|
||||||
|
- **Why the collision problem is mostly already solved:** The Q-028 collision framing assumed NPC-scoped IDs require per-instance ID sequences. They don't. D-028 tagged line pools are role-scoped: `dock-worker_d_001` is content that any dock worker can deliver. Forty dock workers all drawing from `dock-worker_d_###` is correct behavior, not a collision. A collision would only occur if two *distinct authored lines* shared the same ID — which the role namespace prevents by definition (one file, one sequence).
|
||||||
|
- **Instance namespace scope:** Opt-in only. Tier 3 (flat wallpaper) and Tier 2 (mundane triangles) auto-generated NPCs use the role pool exclusively. Instance pools are authored only when a specific generated NPC needs content variation the role pool cannot supply (e.g., a generated NPC flagged as a triangle member with unique tell lines).
|
||||||
|
- **History log disambiguation:** The speaker of a line is identified by `StableId`, not by line ID. Line ID identifies content; `StableId` identifies the speaker. `(StableId: 42, line_id: "dock-worker_d_001")` and `(StableId: 43, line_id: "dock-worker_d_001")` are two different log entries for the same content line — no collision in the log.
|
||||||
|
- **Counter stability:** Generation order within a role group is seeded from the world seed. Same seed → same order → same counter assignments. Counter is recorded in the district's NPC roster at world-gen time. The counter survives save/load because it is part of the generated NPC's profile, not recomputed at runtime.
|
||||||
|
- **Schema compatibility:** The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` already accepts `dock-worker-07_d_001`. No regex change required. No content migration required.
|
||||||
|
- **Rejected alternatives:**
|
||||||
|
- **StableId prefix (`npc-00042_d_001`):** StableId is assigned at load time from sorted canonical IDs. Authors cannot know it before writing files. A generate-then-bake pipeline would break the content/generation separation principle. Rejected.
|
||||||
|
- **UUID suffix (`dock-worker-a3f2_d_001`):** UUIDs are stable per seed but change across seeds, orphaning any authored instance content on replay. Rejected.
|
||||||
|
- **Slug registry with collision resolution (`dock-worker`, `dock-worker-2`, ...):** First instance gets a privileged non-suffixed slug while all others get a counter, creating asymmetry with no upside. Rejected.
|
||||||
|
- **Implementation requirement:** The content registry (`server/src/knowledge/registry.rs` or a new `server/src/content/npc_slug.rs`) tracks a `RoleCounter: BTreeMap<String, u32>` per district. Incremented when a generated NPC claims an instance namespace slot. Stored in the district manifest. Provides `generate_instance_slug(role_slug) -> String` returning `{role-slug}-{counter:02}`.
|
||||||
|
- **Hand-authored NPCs:** Unchanged. `kael-davan`, `sera-venn`, and all named authored NPCs keep their current slugs and ID sequences. No migration.
|
||||||
|
- **Resolves:** Q-028
|
||||||
|
- **Cross-reference:** Line ID scheme ([D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools)), population model ([D-029](#d-029-population-entanglement-ratio--305020)), NPC generation ([D-024](#d-024-npc-generation-model--10-axes--combat-component))
|
||||||
|
- **Raised by:** Gestalt (Sprint 18, #544). Endorsed by Tyre pending implementation review.
|
||||||
|
- **Dissent:** None.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*16 decisions. Last updated: 2026-02-19 (D-075 dissent updated)*
|
### D-093: Sova Transit District — Spatial Layout and District Topology
|
||||||
|
- **Date:** 2026-02-25
|
||||||
|
- **Decision:** The Sova Transit District spatial layout is confirmed. Four social sites (D-025): Terminal (Sova Logistics Hub, 44×28 visual), Bar/Last Shift (28×22 + 6m east ext.), Gate Cluster (40×32 visual, 7 zones — see zone spec below), Sector 3 residential (Drin/Naia anchor, ~15×12 visual, adjacent maintenance spine). Transit platform (The Loop stop, ~12×8 visual, bar-side) is classified as an encounter node, not a social site. District bounding box: 256×256 visual tiles (4×4 blocks per D-094). Three investigation paths confirmed: Path A (pattern recognition via camera logs/manifest), Path B (physical traversal via acoustic gap at restricted storage door seal → maintenance hatch), Path C (institutional, via Commission inspector relationship). Zone palette (surface hex / fog tint): gate cluster #b8bec4 / #0a1222; terminal #7a8490 / #0d1520; bar #6b4018 / #200c04; maintenance #4e5054 / #101214. Gate cluster zone spec (Araminta): aperture chamber 8×4 (restricted); freight staging 24×8 (private); passenger arrival 12×8 (semi-public); freight customs 20×10 (semi-private, 3–5 lanes at 2vt/lane); pedestrian customs 12×10 (semi-public, 3 lanes at 1vt); gate concourse 40×8 (public); observation gallery z=2 32×10 (Commission-only). Corridor widths: maintenance 2vt; internal building 3–4vt; secondary public 4vt; transition 6vt; gate concourse 8vt; service alcoves 1–2vt. Z-level scheme: z=0 maintenance corridor (Era 1, no Meridian), z=1 all main structures, z=2 gate cluster observation gallery only. Cross-z LOS: gallery rail = transparent low wall (player on z=2 sees z=1 below; upward LOS blocked except at staircase). Gate cluster social triangle: operations manager + senior freight handler + Commission inspector. Invisible infrastructure principle (G-08): every ring location reads as mundane; criminal function visible only to those who know. G-11: detective enters via gate cluster (Commission arrival); workers enter via transit platform (bar-side).
|
||||||
|
- **Rationale:** Emerged from three-round workshop synthesis. Layout satisfies D-025 (social sites), D-036 (Sova Transit District), D-054 (tile movement), D-059 (fog zone palette), D-066 (dual-scale grid), D-011 (fog of perception), D-018 (sound model), D-027 (vertical slice criteria). Chunk/district hierarchy establishes architectural precedent for Q-036 generator.
|
||||||
|
- **Raised by:** Full team — Gestalt (gameplay constraints), Miri (worldbuilding/lore), Araminta (visual/spatial), Tyre (technical), Paula (narrative), Ozzie (player experience). Compiled by Qatux.
|
||||||
|
- **Dissent:** Araminta preferred 32×32 visual chunk size (overruled by lead and team majority). No other dissent.
|
||||||
|
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
|
||||||
|
- **Cross-reference:** D-025 (social sites), D-036 (Sova setting), D-054 (tile movement), D-059 (fog system), D-066 (dual-scale grid), D-094 (district hierarchy), D-095 (transport lore), Q-036 (generator), Q-040–Q-044 (transport lore questions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D-095: Horizon Stations and Gate Infrastructure — Transport Lore
|
||||||
|
- **Date:** 2026-02-25
|
||||||
|
- **Decision:** Span gates are human-built structures with a single aperture enabling near-instantaneous transit. Operating schedule uses dual-use windows: freight (bulk of hours) and passenger (scheduled slots). Physical layout: aperture chamber → freight staging / passenger arrival → customs lanes → gate concourse. Horizon stations are alien-built installations (no identified builder species), self-maintaining, located at Oort-cloud distance, with 4–8 apertures per station. Per-system canonical name: "The Ring." Travel is sequential-hop only (A→B→C through intermediate systems; no direct long-range transit). Per-system access tiers vary (4 tiers — some systems allow single-hop to orbital customs; no direct planetary span gate). Station Sova's horizon gates are located at The Ring (Oort-cloud orbital); the Administrative Hub contains booking offices only (not the gates themselves — correction to prior station profile text). "The Loop" is Sova's internal tram network: 6 districts, 4-minute run from Residential Core to Transit District. Workers arrive at the transit platform (bar-side) and disperse to Terminal or bar.
|
||||||
|
- **Rationale:** Resolves transport lore questions Q-040, Q-041, Q-043, Q-044 raised during Workshop #153. Miri's Round 3 contribution. Horizon station as alien-built infrastructure adds worldbuilding depth without requiring a named builder species. Sequential-hop travel creates natural story hooks (layover locations, transit records, smuggling route complexity).
|
||||||
|
- **Raised by:** Miri (worldbuilding), confirmed by team.
|
||||||
|
- **Dissent:** None.
|
||||||
|
- **Source:** Station District Layout Workshop, Ticket #153, Sprint 20. Round document: `docs/discussions/round-20-station-district-layout.md`
|
||||||
|
- **Cross-reference:** D-093 (gate cluster spatial layout), Q-040 (gate dual-use topology — resolved), Q-041 (horizon station model — resolved), Q-042 (intra-system transport — partially resolved), Q-043 (station internal transit — resolved), Q-044 (gate-train integration — resolved)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*21 decisions. Last updated: 2026-02-25 (D-093, D-095 added — Station District Layout Workshop, Sprint 20)*
|
||||||
|
|||||||
+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)
|
- **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)
|
- **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)*
|
||||||
|
|||||||
+115
-4
@@ -161,13 +161,124 @@ Tracked questions awaiting discussion or resolution.
|
|||||||
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
||||||
|
|
||||||
### Q-028: Collision-resistant line IDs for auto-generated NPCs
|
### Q-028: Collision-resistant line IDs for auto-generated NPCs
|
||||||
- **Status:** Open
|
- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override)
|
||||||
- **Question:** The D-035 NPC-scoped line ID scheme uses NPC slugs as prefix (`kael-davan_d_001`). Hand-authored NPCs have unique slugs, but auto-generated populations (D-029: hundreds of NPCs) will produce collisions when the generator creates multiple NPCs with the same role slug (e.g., two `dock-worker` NPCs). What collision-resistance mechanism should be used? Options: (1) Short UUID/hash suffix on auto-gen slugs (`dock-worker-a7f3_d_001`), (2) Entity UUID as prefix, (3) Slug registry that guarantees uniqueness at generation time, (4) Composite key (entity ID + sequence) in server, human-readable slug only for authored content.
|
- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged.
|
||||||
- **Constraints:** Line IDs must be globally unique across entire save file lifetime (history log readiness). Must stay human-readable for hand-authored content. Server treats IDs as opaque strings — solution lives in content/generation layer. Must be compatible with D-035 NPC-scoped namespace.
|
- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25.
|
||||||
- **Ticket:** #544
|
- **Ticket:** #544
|
||||||
- **Assigned to:** Gestalt, Tyre
|
- **Assigned to:** Gestalt, Tyre
|
||||||
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
|
- **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
|
||||||
|
|
||||||
|
### Q-040: Gate dual-use topology — freight and commuter on shared span gate infrastructure
|
||||||
|
- **Status:** Resolved → D-093 (gate cluster zone spec), D-095 (span gate dual-use windows)
|
||||||
|
- **Question:** System span gates serve both freight and commuter traffic (one gate per system). How does this work physically? Is it one gate aperture with scheduling (freight window vs. passenger window), or parallel lanes (separate apertures for freight and passenger flows)? What does the gate facility look like from the inside — a single large bay or divided infrastructure?
|
||||||
|
- **Layout implication:** Affects the gate cluster spatial design in the Transit District — the gate cluster must accommodate both freight staging and passenger throughflow, possibly at different times of day.
|
||||||
|
- **Assigned to:** Miri
|
||||||
|
- **Source:** Station District Layout Workshop (#153), Round 2. Surfaced by lead correction to Miri's S-02 (commuter transit ≠ second external gate).
|
||||||
|
- **Cross-reference:** D-036 (Sova setting), Q-036 (district skeleton as generator output)
|
||||||
|
|
||||||
|
### Q-041: Interstellar travel mechanics — horizon stations and gate architecture
|
||||||
|
- **Status:** Resolved → D-095 (horizon stations: alien-built, 4–8 apertures, Oort-cloud, "The Ring"; sequential hop travel)
|
||||||
|
- **Question:** A system needs MORE than one horizon gate for multi-hop connectivity (one gate allows only 1:1 connections). Lead proposal (Round 3): **Horizon stations** — orbital installations at Oort-cloud distance, partially or wholly understood ancient alien technology, self-maintaining (Mass Effect relay/Citadel analog). Each horizon station holds a FIXED number of active and inactive horizon gates. Some systems may only have one hop to an orbital customs station with no direct planet-side span gate access. Remaining questions: How many gates per horizon station? What determines which gates are active vs. inactive? Is the travel instantaneous or traversal-based? What is "The Ring" (the orbital horizon station) like as a physical space?
|
||||||
|
- **Assigned to:** Miri
|
||||||
|
- **Source:** Station District Layout Workshop (#153), Round 2. Lead correction in Round 3: single-gate-per-system model insufficient for multi-hop travel; horizon station model proposed.
|
||||||
|
- **Cross-reference:** D-036 (Sova setting), Q-039 (gate topology generation), Q-040 (gate dual-use topology)
|
||||||
|
|
||||||
|
### Q-042: Intra-system transport networks — passenger vs. freight, vehicles and modes
|
||||||
|
- **Status:** Partially resolved → D-095 (span gates at star/planetary level; horizon stations at Oort distance). Intra-system hab-to-hab transit remains open.
|
||||||
|
- **Question:** How do people and goods move within a star system (between orbital stations, planetary surfaces, and other in-system facilities)? Are there two separate networks (passenger transport and freight transport) or one shared network? What are the vehicle types and transit modes? How does intra-system transit interact with the span gate at the system's hub station?
|
||||||
|
- **Assigned to:** Miri
|
||||||
|
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
|
||||||
|
- **Cross-reference:** D-036 (Sova setting), Q-043 (station internal transit)
|
||||||
|
|
||||||
|
### Q-043: Station internal transit — intra-station transport system between districts
|
||||||
|
- **Status:** Resolved → D-095 (The Loop: 6-district tram, 4-minute Residential Core → Transit District; transit platform is bar-side encounter node)
|
||||||
|
- **Question:** What is the intra-station transport system on Station Sova? How do workers commute between districts (e.g., Residential Core → Transit District)? Is it a train, tram, shuttle, or pressurised corridor? What is the travel time and frequency? Where does the transit stop sit within the Transit District — gate-cluster-adjacent (workers arrive near freight operations) or bar-side-adjacent (workers arrive near their social space)?
|
||||||
|
- **Layout implication for #153:** The Transit District must include an internal transit stop. Its position within the district affects NPC traffic patterns and the district entry topology. This is the active T-03b question for Round 2/3 of the Station District Layout Workshop.
|
||||||
|
- **Assigned to:** Miri
|
||||||
|
- **Source:** Station District Layout Workshop (#153), Round 2. Arose from lead correction: commuter transit = internal station transit, not a second external gate.
|
||||||
|
- **Cross-reference:** D-036 (Sova setting), Q-042 (intra-system transport networks), S-02 revision
|
||||||
|
|
||||||
|
### Q-044: Gate-train integration — do transport vehicles use gates directly or transfer on each side
|
||||||
|
- **Status:** Resolved → D-093/D-095 (gates are pedestrian/cargo-only; passengers transfer via gate concourse → transition corridor → transit platform; no direct gate-to-tram connection)
|
||||||
|
- **Question:** If trains or shuttles are the intra-system or intra-station transit mode, do they use the span gate directly (a train enters the gate and exits at the destination, carriages and all)? Or are the gates pedestrian/cargo-only, requiring passengers and freight to transfer to separate transport on each side? What does this imply for gate terminal design — does it need platforms, or just processing space?
|
||||||
|
- **Assigned to:** Miri
|
||||||
|
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
|
||||||
|
- **Cross-reference:** Q-040 (gate dual-use topology), Q-043 (station internal transit)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*28 questions (6 resolved, 1 partially resolved, 21 open). Last updated: 2026-02-24*
|
*44 questions (11 resolved, 2 partially resolved, 31 open). Last updated: 2026-02-25 (Q-040, Q-041, Q-043, Q-044 resolved; Q-042 partially resolved — Station District Layout Workshop #153 Round 3)*
|
||||||
|
|||||||
+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)
|
- **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.
|
- **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
|
### Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests (test-server + test-client)
|
||||||
make test-server # cargo test in server/
|
make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary)
|
||||||
make test-client # gdUnit4 tests (headless runner pending)
|
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).
|
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
|
### Cross-Encoder Fixtures
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,606 @@
|
|||||||
|
# Insert/HUD Wireframe and Visual Specification — v0.1
|
||||||
|
## Dual Character Variants: Smuggler + Detective
|
||||||
|
|
||||||
|
**Ticket:** #314
|
||||||
|
**Author:** Araminta (Visual Designer)
|
||||||
|
**Date:** 2026-02-24
|
||||||
|
**Status:** Active — feeds #263 (time display client implementation) and #151 (minimap rendering)
|
||||||
|
**Visual grammar reference:** `docs/design/visual-grammar-v01.md` (§7 Neural Insert Overlay)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Architecture
|
||||||
|
|
||||||
|
The insert HUD is not a UI panel bolted onto the game. It is the player character's **neural lattice** — Settled Reach technology that overlays computational data onto the character's perception. Every element on z-layer 6 is diegetic: the character is experiencing this information through their implant, not reading a game interface.
|
||||||
|
|
||||||
|
This has direct design consequences:
|
||||||
|
|
||||||
|
- The insert does not appear/disappear — it activates and deactivates. The character's lattice is always there; they choose when to engage it.
|
||||||
|
- Labels use the character's vocabulary, not the game's. The smuggler's insert calls it "The Last Shift." The detective's insert calls it "Licensed Establishment, District 7."
|
||||||
|
- The same underlying `ObserverSnapshot` feeds both variants. One parameterized system: `lattice_profile` on the snapshot determines which visual mode renders. Stig builds one HUD framework, not two. The variants are rendering configurations, not separate systems.
|
||||||
|
|
||||||
|
### 1.1 Technical Parameters
|
||||||
|
|
||||||
|
| Parameter | Smuggler | Detective |
|
||||||
|
|-----------|----------|-----------|
|
||||||
|
| `lattice_profile` | `lattice_baseline` | `lattice_augmented` |
|
||||||
|
| Line weight | 1px | 1–2px |
|
||||||
|
| Annotation density | Sparse — essential markers only | Dense — more markers, connection lines |
|
||||||
|
| Passive opacity | 60% | 80% |
|
||||||
|
| Active opacity | 85% | 100% |
|
||||||
|
| Bloom | 2–3px gaussian at 40% blend | 2–3px gaussian at 40% blend |
|
||||||
|
| Chrome color | `#c8d0e0` | `#c8d0e0` |
|
||||||
|
|
||||||
|
### 1.2 Z-Layer Position
|
||||||
|
|
||||||
|
Insert HUD lives entirely on **z-layer 6**. It renders above fog (z-layer 5) and below monologue/UI (z-layer 7). The fog shader does not mask it — insert data is computational, not perceptual. Entity markers can appear in fogged areas if the lattice has position data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Screen Layout
|
||||||
|
|
||||||
|
**Base resolution:** 1920×1080px
|
||||||
|
|
||||||
|
The HUD has four zones:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [STANCE] [PERCEPTION] [TIME] │ ← HUD chrome band (y: 0–80px)
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ WORLD VIEW │
|
||||||
|
│ (insert entity markers rendered here, │
|
||||||
|
│ positioned at entity world locations) │
|
||||||
|
│ │
|
||||||
|
│ ↑ ↑ ↑ ↑ border arrows appear here │
|
||||||
|
│ ← (for off-screen known POIs) → │
|
||||||
|
│ ↓ ↓ ↓ ↓ │
|
||||||
|
│ │
|
||||||
|
│ [INVENTORY] [INVENTORY] │ ← inventory band (y: 816–864px)
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ DIALOGUE BOX (z-layer 7, when active) │ ← bottom 20% (y: 864–1080px)
|
||||||
|
│ [NPC speech] │
|
||||||
|
│ [response options] │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coordinate system:** Origin (0,0) is top-left. X increases rightward, Y increases downward. All pixel values at 1920×1080 base; Godot 4 DPI scaling handles other resolutions.
|
||||||
|
|
||||||
|
**Dialogue box:** The bottom 20% (216px) is reserved when dialogue is active. Insert overlay remains visible at 60% of its normal opacity when dialogue is open — the lattice keeps running, the character keeps perceiving. The insert does not suppress during dialogue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Time Display
|
||||||
|
|
||||||
|
**Feeds:** Ticket #263 (client time display implementation)
|
||||||
|
|
||||||
|
### 3.1 Position and Dimensions
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Container x | 1720px → 1904px (right-inset 16px, 184px wide) |
|
||||||
|
| Container y | 16px → 64px (48px tall) |
|
||||||
|
| Background | `#0e1218` at 40% opacity — barely-there panel behind text |
|
||||||
|
| Typeface | Michroma Regular 400 |
|
||||||
|
| Alignment | Right-aligned text, right-inset 8px within container |
|
||||||
|
|
||||||
|
This position is intentionally unremarkable. The player should find it without hunting for it.
|
||||||
|
|
||||||
|
### 3.2 Smuggler Variant
|
||||||
|
|
||||||
|
Single line: the time. Informal, just the clock.
|
||||||
|
|
||||||
|
```
|
||||||
|
09:42
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Text | `{HH}:{MM}` — 24-hour format, always two digits each |
|
||||||
|
| Font size | 24px |
|
||||||
|
| Color | `#c8d0e0` (insert chrome) |
|
||||||
|
| Opacity | 80% passive, 100% when insert active and entity hover |
|
||||||
|
| Y position | Center within container, ~y: 32px |
|
||||||
|
|
||||||
|
No annotation. The smuggler's lattice gives them the time. That's it.
|
||||||
|
|
||||||
|
### 3.3 Detective Variant
|
||||||
|
|
||||||
|
Two lines: clock on top, shift indicator below. The detective thinks in shifts.
|
||||||
|
|
||||||
|
```
|
||||||
|
09:42
|
||||||
|
SHIFT A ·
|
||||||
|
```
|
||||||
|
|
||||||
|
| Element | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Time text | `{HH}:{MM}` — Michroma 20px, `#c8d0e0` at 80% passive, y: 26px in container |
|
||||||
|
| Phase label | `SHIFT {X}` — Michroma 11px, `#c8d0e0` at 60%, y: 50px in container |
|
||||||
|
| Phase dot | 4px circle, phase-color (see below), same y as phase label |
|
||||||
|
|
||||||
|
**Day phase to shift mapping:**
|
||||||
|
|
||||||
|
| Game time | Shift label | Dot color |
|
||||||
|
|-----------|-------------|-----------|
|
||||||
|
| 05:00–12:00 | `SHIFT A` | `#c8d0e0` (chrome, neutral) |
|
||||||
|
| 12:00–18:00 | `SHIFT B` | `#c8d0e0` (chrome, neutral) |
|
||||||
|
| 18:00–22:00 | `SHIFT C` | `#e8c547` (amber — social hours, higher risk of contacts) |
|
||||||
|
| 22:00–05:00 | `SHIFT OFF` | `#4a9ebb` (teal — night, lower routine activity) |
|
||||||
|
|
||||||
|
The dot is a subtle visual cue, not a state machine icon. It gives the detective's lattice a slightly more annotated feel than the smuggler's plain clock.
|
||||||
|
|
||||||
|
### 3.4 Insert-Off Behavior
|
||||||
|
|
||||||
|
When `insert_active == false`:
|
||||||
|
- Time display dims to 50% of passive opacity
|
||||||
|
- Background panel disappears entirely
|
||||||
|
- Text remains — the character always knows what time it is, even without active lattice engagement. Time is ambient lattice output, not targeting output.
|
||||||
|
- Phase label (detective) also dims to 50%
|
||||||
|
|
||||||
|
The time display is the one persistent insert element. Everything else goes dark on insert deactivation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Entity Markers
|
||||||
|
|
||||||
|
Entity markers appear at each entity's world position — rendered in world space on z-layer 6. They are not minimap icons in a panel; they annotate the world the player is looking at.
|
||||||
|
|
||||||
|
### 4.1 Smuggler Variant — Social Network View
|
||||||
|
|
||||||
|
The smuggler sees their world as a social network. People are dots. Labels are familiar names.
|
||||||
|
|
||||||
|
**Dot specification:**
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Shape | Filled circle |
|
||||||
|
| Diameter | 6px |
|
||||||
|
| Color | D-033 relationship color for this NPC |
|
||||||
|
| Bloom | 2–3px gaussian at 40% blend |
|
||||||
|
| Passive state | 60% opacity |
|
||||||
|
| Active state (hover) | 100% opacity + bloom brightens |
|
||||||
|
| All non-hovered on hover | 40% opacity (focus effect) |
|
||||||
|
|
||||||
|
The dot sits at the entity's sprite anchor point (center-bottom of the 64×64 canvas). It does not float; it attaches to the entity.
|
||||||
|
|
||||||
|
**Label specification:**
|
||||||
|
|
||||||
|
Named NPCs (Kael, Lera, Sera, etc.) always show a casual label. Generic NPCs show no label unless hovered.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Typeface | Michroma 11px |
|
||||||
|
| Color | `#c8d0e0` at 70% passive, 100% on hover |
|
||||||
|
| Position | 8px above the dot, horizontally centered |
|
||||||
|
| Format | First name only for Known NPCs: `Kael` |
|
||||||
|
| Format | Location nickname for POIs: `The Last Shift`, `Kael's usual spot` |
|
||||||
|
| Background | None — label is direct on world view. Keep it legible against zone palettes. |
|
||||||
|
|
||||||
|
**Relationship colors applied:**
|
||||||
|
|
||||||
|
| NPC state | Dot color | Label format |
|
||||||
|
|-----------|-----------|--------------|
|
||||||
|
| Unknown | `#4a9ebb` teal | No label (generic) |
|
||||||
|
| Known/Friendly | `#6bc9a6` green | First name |
|
||||||
|
| Person of Interest | `#e8c547` amber | First name + `?` suffix: `Torek?` |
|
||||||
|
| Hostile | `#d45d5d` red | First name |
|
||||||
|
|
||||||
|
The smuggler does not see "Person of Interest." To them, amber means "something's off about this person" — not a case file designation.
|
||||||
|
|
||||||
|
### 4.2 Detective Variant — Investigation Overlay
|
||||||
|
|
||||||
|
The detective sees their world as a case map. Known individuals are flagged. Locations have institutional labels. The overlay is denser and more annotated.
|
||||||
|
|
||||||
|
**Named NPC marker specification:**
|
||||||
|
|
||||||
|
| NPC type | Shape | Size | Color |
|
||||||
|
|----------|-------|------|-------|
|
||||||
|
| Person of Interest | Diamond (rotated square) | 10px × 10px | `#e8c547` amber |
|
||||||
|
| Known/Friendly | Filled circle | 6px | `#6bc9a6` green |
|
||||||
|
| Unknown | Filled circle | 6px | `#4a9ebb` teal |
|
||||||
|
| Hostile / Suspect | Diamond | 10px × 10px | `#d45d5d` red |
|
||||||
|
|
||||||
|
The diamond shape is the detective's POI designation — it communicates "flagged" without text. The smuggler's variant uses only circles because their lattice doesn't have case-file designation logic.
|
||||||
|
|
||||||
|
**Label specification:**
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Typeface | Michroma 11px |
|
||||||
|
| Color | `#c8d0e0` at 80% passive, 100% on hover |
|
||||||
|
| Position | 10px above the marker, right-aligned to marker center |
|
||||||
|
| NPC label format | `POI: K. Davan` (formal: last name initial + surname) |
|
||||||
|
| NPC label format | `K. Davan` for Known/non-POI |
|
||||||
|
| Location label | See §5 |
|
||||||
|
|
||||||
|
**Evidence annotation:**
|
||||||
|
|
||||||
|
When the detective has logged evidence associated with an entity, a small square appears adjacent to their marker:
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Shape | Filled square |
|
||||||
|
| Size | 4px × 4px |
|
||||||
|
| Position | 4px right of the primary marker |
|
||||||
|
| Color | `#e8c547` amber |
|
||||||
|
| Opacity | 60% passive, 100% on hover |
|
||||||
|
|
||||||
|
This is the insert's way of flagging "you have something on this person." It doesn't say what.
|
||||||
|
|
||||||
|
**Commission grid overlay (Detective only):**
|
||||||
|
|
||||||
|
The detective's augmented lattice renders a very faint reference grid across the viewport. This is the "commission-style" aesthetic — the world read as a site map.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Grid lines | Horizontal + vertical, every 5 visual tiles (~320px) |
|
||||||
|
| Line color | `#333340` at 20% opacity |
|
||||||
|
| Line weight | 1px |
|
||||||
|
| Behavior | Always visible when insert active, disappears on insert-off |
|
||||||
|
|
||||||
|
The grid does not animate, pulse, or react. It is a passive reference overlay. Five lines across the width, five down the height, making a 5×5 grid of reference squares. It is barely visible — felt as structure, not seen as decoration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Location Labels
|
||||||
|
|
||||||
|
Location labels appear at POI anchor points in world space — the entrance tile or center tile of a known location.
|
||||||
|
|
||||||
|
### 5.1 Smuggler Variant
|
||||||
|
|
||||||
|
Casual labels, written in the smuggler's vocabulary. These are their personal pins.
|
||||||
|
|
||||||
|
| Location | Label text |
|
||||||
|
|----------|-----------|
|
||||||
|
| The Last Shift (bar) | `The Last Shift` |
|
||||||
|
| The Terminal (logistics hub) | `The Terminal` |
|
||||||
|
| Maintenance corridor (smuggling space) | `B-7 drop` (or specific pin label) |
|
||||||
|
| Kael's regular position | `Kael's usual spot` |
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Typeface | Michroma 12px |
|
||||||
|
| Color | `#c8d0e0` at 50% — location labels are backgrounded, not foregrounded |
|
||||||
|
| Position | Centered on location anchor, 16px above entity-level (below any entity markers at that location) |
|
||||||
|
| Visibility | Always visible for known locations, fades in on discovery |
|
||||||
|
|
||||||
|
### 5.2 Detective Variant
|
||||||
|
|
||||||
|
Institutional labels, written in Commission case file format.
|
||||||
|
|
||||||
|
| Location | Label text |
|
||||||
|
|----------|-----------|
|
||||||
|
| The Last Shift (bar) | `Licensed Establishment · District 7` |
|
||||||
|
| The Terminal (logistics hub) | `Freight Operations Hub · Level 2` |
|
||||||
|
| Maintenance corridor | `Restricted Access — Corridor B-7` |
|
||||||
|
| Kael's regular position | `K. Davan – Last Sighted` |
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Typeface | Michroma 11px |
|
||||||
|
| Color | `#c8d0e0` at 70% — slightly more prominent than smuggler's labels |
|
||||||
|
| Position | Same as smuggler, centered on anchor |
|
||||||
|
| Secondary text | Dot separator `·` between primary name and category/district |
|
||||||
|
| Visibility | Always visible for known/case-file locations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Border Arrows (Off-Screen Navigation)
|
||||||
|
|
||||||
|
When a known POI or tracked entity is outside the current viewport, a border arrow appears at the screen edge pointing toward them.
|
||||||
|
|
||||||
|
**The camera is locked to the player character (D-014).** Border arrows are the only navigation affordance for off-screen known locations. There is no minimap panel.
|
||||||
|
|
||||||
|
### 6.1 Arrow Specification
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Shape | Equilateral triangle, apex pointing toward screen edge |
|
||||||
|
| Size | 12px × 10px |
|
||||||
|
| Inset from edge | 8px |
|
||||||
|
| Color | Matches the POI's relationship color (D-033), or `#c8d0e0` chrome for neutral known locations |
|
||||||
|
| Opacity | 60% passive, 100% on hover |
|
||||||
|
| Label on hover | Location name (using appropriate variant's label format), Michroma 10px, appears inside-screen adjacent to arrow |
|
||||||
|
|
||||||
|
**Clustering:** When multiple POIs are off-screen in similar directions, arrows stack with 4px gap. Maximum 6 arrows per edge before clustering into a count indicator: `+3` (Michroma 10px, chrome color).
|
||||||
|
|
||||||
|
**Decay:** If the character has only `Suspects` confidence about a location (not `KnowsOf`), the border arrow renders at 30% opacity with a dashed border (1px alternating 2px on / 2px off). "Something might be there."
|
||||||
|
|
||||||
|
### 6.2 Smuggler vs Detective
|
||||||
|
|
||||||
|
- **Smuggler:** Border arrows appear for Known/Friendly NPCs and personal pins. The arrow color is the NPC's relationship color. The label is casual. Unknown NPCs do not generate border arrows.
|
||||||
|
- **Detective:** Border arrows appear for all case-file entries, POIs, and known locations regardless of relationship state. Evidence-flagged entities show the amber evidence square (4px) adjacent to the arrow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Inventory Indicators (Smuggler Only)
|
||||||
|
|
||||||
|
Per D-065: the smuggler can carry up to 3–4 physical items. Icons appear in the lower-right corner, above the dialogue band.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Position | x: 1904px (right inset 16px), y: 816px → 856px |
|
||||||
|
| Icon size | 40×40px |
|
||||||
|
| Gap between icons | 8px horizontal, right-to-left stacking |
|
||||||
|
| Opacity | 80% passive, 100% on hover |
|
||||||
|
| Empty slots | Not displayed — icons only appear when items are carried |
|
||||||
|
| Z-layer | 6 (insert layer), but icons are physical objects, not lattice data |
|
||||||
|
|
||||||
|
**Item icons (placeholder descriptions for #314 — actual sprites are a separate ticket):**
|
||||||
|
- Manifest copy: document silhouette
|
||||||
|
- Corridor access token: hexagonal tag silhouette
|
||||||
|
- Personal comm log: communication device silhouette
|
||||||
|
|
||||||
|
Icons do not disappear when insert is off — they are physical items, not lattice annotations. The character still has pockets whether or not their insert is active.
|
||||||
|
|
||||||
|
**Detective inventory:** The detective has 2 inventory slots but they are knowledge-only in v0.1 (D-065). No inventory icons display for the detective in v0.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Stance Indicator
|
||||||
|
|
||||||
|
Both variants show the current movement stance. This is ambient information — the player set it, but may have forgotten.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Position | x: 16px → 180px, y: 826px → 856px (lower-left, above dialogue band) |
|
||||||
|
| Typeface | Michroma 11px |
|
||||||
|
| Color | `#c8d0e0` at 50% — intentionally quiet |
|
||||||
|
| Content | Current stance label: `WALK` / `CAREFUL` / `SPRINT` / `CROUCH` |
|
||||||
|
| Behavior | Fades to 30% when dialogue is active |
|
||||||
|
|
||||||
|
The stance indicator never hides; it just dims. It is the quietest element in the HUD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Interaction States
|
||||||
|
|
||||||
|
### 9.1 Insert Active — Default State
|
||||||
|
|
||||||
|
Full overlay as described in §3–§8. Entity markers, location labels, border arrows, time display, stance indicator, inventory icons all render at their passive opacity values.
|
||||||
|
|
||||||
|
### 9.2 Insert Inactive (D-056/D-057)
|
||||||
|
|
||||||
|
When `insert_active == false`:
|
||||||
|
|
||||||
|
| Element | State |
|
||||||
|
|---------|-------|
|
||||||
|
| Entity markers | Hidden |
|
||||||
|
| Location labels | Hidden |
|
||||||
|
| Border arrows | Hidden |
|
||||||
|
| Commission grid (detective) | Hidden |
|
||||||
|
| Evidence annotations | Hidden |
|
||||||
|
| Bloom | Off — no gaussian pass |
|
||||||
|
| Time display | Dims to 50% of passive opacity, background panel disappears |
|
||||||
|
| Stance indicator | Remains — 30% opacity |
|
||||||
|
| Inventory icons | Remain — physical, not insert data |
|
||||||
|
| Cursor | Shape still changes on entity proximity (subconscious physical response per D-056) |
|
||||||
|
|
||||||
|
The world goes quiet. The character still moves, still perceives physically, still has their items. But the lattice stops annotating.
|
||||||
|
|
||||||
|
### 9.3 Entity Hover (Insert Active)
|
||||||
|
|
||||||
|
When the player's cursor enters an entity's interaction radius:
|
||||||
|
|
||||||
|
1. Hovered entity's marker brightens to 100% opacity + bloom intensifies to 60%
|
||||||
|
2. All other entity markers dim to 40% opacity
|
||||||
|
3. Interaction verb appears: Michroma 13px, D-033 color of the target, positioned 16px above the entity marker
|
||||||
|
4. Entity label brightens to 100%
|
||||||
|
|
||||||
|
The interaction verb is the primary affordance for engagement. The verb appears on the insert layer — it disappears when insert is off, and is unaffected by fog (since it's z-layer 6).
|
||||||
|
|
||||||
|
Verb examples: `Talk`, `Observe`, `Follow`, `Confront`, `Examine`
|
||||||
|
|
||||||
|
### 9.4 Dialogue Active
|
||||||
|
|
||||||
|
When a dialogue interaction is in progress:
|
||||||
|
|
||||||
|
| Element | Change |
|
||||||
|
|---------|--------|
|
||||||
|
| Dialogue box | Appears at bottom 20% (y: 864–1080), z-layer 7 |
|
||||||
|
| Monologue (if active) | Floats above dialogue box at y: ~800–860, z-layer 7 |
|
||||||
|
| Insert overlay | Reduces to 60% of active opacity — still visible, lattice still running |
|
||||||
|
| Entity markers | Remain visible but dimmed 40% |
|
||||||
|
| Hovered entity marker | Stays bright — the conversation target stays highlighted |
|
||||||
|
| Inventory icons | Remain visible |
|
||||||
|
| Time display | Remains |
|
||||||
|
| Stance indicator | Dims to 30% |
|
||||||
|
|
||||||
|
The insert doesn't close during dialogue. The character is still aware of their surroundings.
|
||||||
|
|
||||||
|
### 9.5 High Perception Load (D-059)
|
||||||
|
|
||||||
|
When multiple perception modes are active simultaneously, the insert shows diegetic interference:
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Effect | Horizontal scan-line distortion |
|
||||||
|
| Intensity | 5% — subtle, peripheral |
|
||||||
|
| Behavior | Periodic (not constant), short bursts, random intervals 2–8 seconds |
|
||||||
|
| Applies to | All insert elements simultaneously |
|
||||||
|
| Does NOT apply to | UI z-layer 7 (monologue, dialogue) |
|
||||||
|
|
||||||
|
This is a visual warning, not a mechanical penalty. The player reads it as "the lattice is working hard." It should not be alarming; it should be ambient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Wireframe Mockups
|
||||||
|
|
||||||
|
### 10.1 Smuggler Variant — The Last Shift, Evening
|
||||||
|
|
||||||
|
The smuggler is in the bar. Kael is nearby (green — Known/Friendly). An unknown dock worker is in the corner (teal). The bar is a known location.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CAREFUL 09:42 │
|
||||||
|
│ │
|
||||||
|
│ ↑ The Last Shift (bar: border direction) │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ● ● │
|
||||||
|
│ Kael [player] ● │
|
||||||
|
│ (green) (cream) (teal) │
|
||||||
|
│ │
|
||||||
|
│ The Last Shift │
|
||||||
|
│ (chrome label, centered) │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ [manifest][token]│
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [dialogue box — inactive] │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
● = entity dot marker (6px) Kael = label (Michroma 11px, chrome)
|
||||||
|
The Last Shift = location label (Michroma 12px, 50% chrome)
|
||||||
|
[manifest][token] = inventory icons (40×40px, lower-right)
|
||||||
|
09:42 = time display (Michroma 24px, upper-right)
|
||||||
|
CAREFUL = stance (Michroma 11px, lower-left, 50%)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Detective Variant — Same Location, Same Time
|
||||||
|
|
||||||
|
The detective is outside the bar, watching the entrance. Kael is inside, behind a wall — the lattice shows his last known position as a dim marker. A POI is approaching from the corridor (off-screen right).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WALK [ANALYT] 09:42 │
|
||||||
|
│ SHIFT C · │
|
||||||
|
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
|
||||||
|
│ · ·│
|
||||||
|
│ · ◆ [player] → ·│
|
||||||
|
│ · K. Davan (blue-wh) [POI: arrow, amber, off-screen]·│
|
||||||
|
│ · (amber diamond) ·│
|
||||||
|
│ · ·│
|
||||||
|
│ · Licensed Establishment · District 7 ·│
|
||||||
|
│ +·····+·····+·····+·····+·····+·····+·····+·····+·····+·····+ │
|
||||||
|
│ · ·│
|
||||||
|
│ · ·│
|
||||||
|
│ │
|
||||||
|
│ WALK │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [dialogue box — inactive] │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Legend:
|
||||||
|
◆ = POI diamond marker (10×10px) K. Davan = formal label (Michroma 11px)
|
||||||
|
+·····+ = commission grid overlay (Michroma thin, 20% opacity)
|
||||||
|
→ = border arrow (off-screen POI, amber — K. Davan connection)
|
||||||
|
Licensed Establishment · District 7 = institutional location label
|
||||||
|
09:42 / SHIFT C · = time display + shift with amber phase dot
|
||||||
|
[ANALYT] = perception mode indicator (placeholder position, upper-center-right)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Entity Hover State — Detective, POI Hover
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WALK [ANALYT] 09:42 │
|
||||||
|
│ SHIFT C · │
|
||||||
|
│ │
|
||||||
|
│ Talk │
|
||||||
|
│ Observe │
|
||||||
|
│ ◆ Confront ←─ interaction verbs (amber, 13px) │
|
||||||
|
│ K. Davan │
|
||||||
|
│ [■]◆ (brightened, 100%, bloom 60%) │
|
||||||
|
│ │
|
||||||
|
│ all other markers at 40% opacity ─────────────────────────→ │
|
||||||
|
│ ● │
|
||||||
|
│ (40%) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
[■] = evidence annotation (4×4px amber square, adjacent to diamond)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Color Reference Summary
|
||||||
|
|
||||||
|
All colors from `visual-grammar-v01.md` §1.5 and §3.2. Reproduced here for implementation reference.
|
||||||
|
|
||||||
|
| Use | Hex | Notes |
|
||||||
|
|-----|-----|-------|
|
||||||
|
| Insert chrome (default) | `#c8d0e0` | Time display, labels, stance, grid |
|
||||||
|
| Entity — Unknown | `#4a9ebb` | Cool teal. Default NPC state. |
|
||||||
|
| Entity — Known/Friendly | `#6bc9a6` | Soft green. Trusted network. |
|
||||||
|
| Entity — POI | `#e8c547` | Warm amber. Flagged. |
|
||||||
|
| Entity — Hostile | `#d45d5d` | Muted red. Subjective danger. |
|
||||||
|
| Player — Detective | `#e0e8ff` | Cool blue-white. |
|
||||||
|
| Player — Smuggler | `#e8e0d0` | Warm cream. |
|
||||||
|
| Unexplored wireframe | `#333340` | Not used in HUD chrome directly — world layer use |
|
||||||
|
| Time panel background | `#0e1218` at 40% | Barely-there. Matches zone ambient palette. |
|
||||||
|
|
||||||
|
**Saturation rule:** Entity D-033 colors must remain the most saturated elements on screen at all times. Nothing in the insert chrome may compete with entity color saturation (D-044, §3.6 of visual grammar). The chrome is cool grey-white — it frames entity colors, never rivals them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Typography Reference
|
||||||
|
|
||||||
|
All text is Michroma Regular 400 (Google Fonts). No exceptions (visual-grammar-v01.md §5.4).
|
||||||
|
|
||||||
|
| Role | Size | Color | Opacity (passive / active) | Z-layer |
|
||||||
|
|------|------|-------|---------------------------|---------|
|
||||||
|
| Time display | 24px (smuggler) / 20px (detective) | `#c8d0e0` | 80% / 100% | 6 |
|
||||||
|
| Phase label (detective) | 11px | `#c8d0e0` | 60% / 80% | 6 |
|
||||||
|
| Entity label (hover) | 11px | `#c8d0e0` | 70% / 100% | 6 |
|
||||||
|
| Location label (smuggler) | 12px | `#c8d0e0` | 50% / 70% | 6 |
|
||||||
|
| Location label (detective) | 11px | `#c8d0e0` | 70% / 90% | 6 |
|
||||||
|
| Interaction verb | 13px | D-033 target color | 95% (always active) | 6 |
|
||||||
|
| Stance indicator | 11px | `#c8d0e0` | 50% / 60% | 6 |
|
||||||
|
| Border arrow label (hover) | 10px | `#c8d0e0` | 0% / 100% (hover-only) | 6 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. What This Document Does Not Cover
|
||||||
|
|
||||||
|
These are deliberately out of scope for #314. Separate tickets own them.
|
||||||
|
|
||||||
|
- **Perception mode overlay visual design** — thermal, camera feed, etc. (#315 area, depends on D-017/D-048 full spec)
|
||||||
|
- **Monologue display system** — typography animation, stacking (#315)
|
||||||
|
- **Sound indicator visual design** — fog-edge ping pulse (#317)
|
||||||
|
- **THE FRIEND visual treatment** — special relationship visual affordances (#318)
|
||||||
|
- **Minimap panel** (if a panel approach is ever adopted — currently border arrows handle navigation, no panel exists in v0.1)
|
||||||
|
- **World map / inter-location navigation** — a different system entirely
|
||||||
|
- **Cursor states** — specified in D-056, not repeated here
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Integration Notes for Stig (#263)
|
||||||
|
|
||||||
|
**For the time display implementation (#263):**
|
||||||
|
|
||||||
|
1. The time display container is at **x: 1720–1904, y: 16–64** (right-inset 16px from 1920px width). These are your anchor coordinates.
|
||||||
|
2. The `ObserverSnapshot` needs to expose current game time (hours, minutes) and current day phase. Consult D-031 for the tick-to-time mapping (10 ticks = 1 game-minute).
|
||||||
|
3. Time display text must be **right-aligned** within the container. Don't center it — it should feel like a readout in the upper-right corner.
|
||||||
|
4. The background panel (`#0e1218` at 40%) should be a simple NinePatchRect or ColorRect — minimal, no rounded corners.
|
||||||
|
5. When `insert_active == false`: set time display `modulate.a = 0.5 * passive_opacity` and hide the background panel. The text stays; the container chrome disappears.
|
||||||
|
6. For the detective's phase label: drive the shift string from a simple range lookup on the game hour (see §3.3 table above). The dot color is a color constant mapped from phase — not dynamic.
|
||||||
|
7. The phase label is visible only on `lattice_profile == lattice_augmented`. Single boolean check in the time display node.
|
||||||
|
|
||||||
|
**For the entity marker system (future tickets):**
|
||||||
|
|
||||||
|
- One marker scene, parametrized by shape (circle vs diamond), size, color, bloom intensity
|
||||||
|
- The `lattice_profile` drives shape selection: baseline = circles always, augmented = diamonds for POI state, circles for other states
|
||||||
|
- Evidence annotation (`■`) is a separate child node, shown/hidden based on KnowledgeGraph query for evidence entries on this entity
|
||||||
|
- Commission grid is a separate CanvasLayer node, only visible when `lattice_profile == lattice_augmented` and `insert_active == true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B — Decision Cross-References
|
||||||
|
|
||||||
|
| Decision | Relevance |
|
||||||
|
|----------|-----------|
|
||||||
|
| D-013 | Diegetic insert/POI navigation. The HUD IS the neural insert. |
|
||||||
|
| D-014 | Minimap spec: "dots when close, border arrows for known distant POIs" |
|
||||||
|
| D-019 | Camera tilt — sprites authored for shallow tilt. Markers sit above entity anchor. |
|
||||||
|
| D-031 | Time system — tick-to-time mapping, day phases. Feeds §3. |
|
||||||
|
| D-033 | Entity color = relationship to player. All marker colors source from this. |
|
||||||
|
| D-048 | Neural insert visual design. Bloom spec, character variants. |
|
||||||
|
| D-049 | Z-layer 6 = insert overlay. Z-layer 7 = UI/monologue. |
|
||||||
|
| D-051 | "Settling is placement." Object density as meaning. Insert labels reflect this. |
|
||||||
|
| D-056 | Cursor states persist when insert-off. |
|
||||||
|
| D-057 | Entity interaction labels suppressed when insert-off. |
|
||||||
|
| D-059 | Perception load → scan-line interference at 5%. |
|
||||||
|
| D-065 | Inventory: 40×40px icons, smuggler-only in v0.1. |
|
||||||
|
| D-066 | Dual-scale grid. Entity markers anchor to visual tile positions. |
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Line ID Authoring Guide
|
||||||
|
|
||||||
|
**Decision:** D-084 (dual-namespace line ID scheme)
|
||||||
|
**Resolves:** Q-028 (collision-resistant IDs for auto-generated NPCs)
|
||||||
|
**Ticket:** #544
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Short Version
|
||||||
|
|
||||||
|
- **Role pool lines:** Use `{role-slug}_d_{###}` — e.g., `dock-worker_d_001`. These lines are shared by all NPCs with that role. This is the default for all auto-generated NPC content.
|
||||||
|
- **Named NPC lines:** Use `{npc-slug}_d_{###}` — e.g., `kael-davan_d_001`. Unchanged from current practice.
|
||||||
|
- **Instance-specific lines (rare):** Use `{role-slug}-{counter}_d_{###}` — e.g., `dock-worker-07_d_001`. Only needed when a specific generated NPC needs content different from the role pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Line IDs Work
|
||||||
|
|
||||||
|
A line ID identifies **content**, not speaker. The speaker is identified by their `StableId` in the history log. So `dock-worker_d_001` being said by 40 different dock workers is correct: the log records `(StableId: 12, dock-worker_d_001)`, `(StableId: 37, dock-worker_d_001)`, etc. No collision.
|
||||||
|
|
||||||
|
This means the role pool approach already handles most cases — the "collision problem" is mainly a concern for the rare case where you want a specific generated NPC to say something *different* from others of the same role.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Namespace Reference
|
||||||
|
|
||||||
|
### Named NPC lines (Tier 1 and Tier 2 authored NPCs)
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: {npc-slug}_{content-type}_{###}
|
||||||
|
Example: kael-davan_d_001 (Kael's dialogue line 1)
|
||||||
|
sera-venn_d_015 (Sera's dialogue line 15)
|
||||||
|
pc-smuggler_m_s_001 (Smuggler monologue line 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
File location: One file per NPC (e.g., `dialogue/maintenance-corridors/kael-davan.yaml`)
|
||||||
|
|
||||||
|
Numbering: Sequential within the file. Gaps are acceptable (deleted lines leave permanent gaps). Never reuse a number.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Role pool lines (auto-generated NPCs, Tier 3 flat, Tier 2 mundane)
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: {role-slug}_{content-type}_{###}
|
||||||
|
Example: dock-worker_d_001 (any dock worker, dialogue line 1)
|
||||||
|
bar-regular_d_008 (any bar regular, dialogue line 8)
|
||||||
|
transit-worker_d_003 (any transit worker, dialogue line 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
File location: One file per role-at-location (e.g., `dialogue/the-terminal/dock-worker.yaml`)
|
||||||
|
|
||||||
|
These lines are shared by **all instances** of the role. Write them to suit any dock worker, not a specific one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Instance-specific lines (opt-in, rare)
|
||||||
|
|
||||||
|
Use only when the generation system has flagged a specific NPC as needing content that differs from the role pool. Examples: a generated dock worker who is also a triangle member with a specific tell; a generated bar regular who witnessed a specific event.
|
||||||
|
|
||||||
|
```
|
||||||
|
Format: {role-slug}-{zero-padded counter}_{content-type}_{###}
|
||||||
|
Example: dock-worker-07_d_001 (instance 7 of dock-worker role, line 1)
|
||||||
|
bar-regular-02_d_005 (instance 2 of bar-regular role, line 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
The counter (01, 02, ... N) is assigned by the generation system in world-seed-deterministic order. The NPC's generated profile file will tell you which counter to use.
|
||||||
|
|
||||||
|
File location: Same directory as the role pool file, separate file with instance slug as name (e.g., `dialogue/the-terminal/dock-worker-07.yaml`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Decision Guide
|
||||||
|
|
||||||
|
| Situation | ID format to use |
|
||||||
|
|-----------|------------------|
|
||||||
|
| Named authored NPC (Kael, Sera, Voss...) | `{npc-slug}_d_{###}` |
|
||||||
|
| Lines any dock worker can say | `dock-worker_d_{###}` |
|
||||||
|
| Lines any bar regular can say | `bar-regular_d_{###}` |
|
||||||
|
| Generated NPC with specific triangle role | `{role-slug}-{counter}_d_{###}` |
|
||||||
|
| Generated NPC who's just background | `{role-slug}_d_{###}` — no instance ID needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema Compatibility
|
||||||
|
|
||||||
|
The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` accepts all three formats. No schema change is required. The content validator (`make validate-content`) checks for duplicate IDs across all files in a district.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Numbering Rules
|
||||||
|
|
||||||
|
1. Start at `001`, increment by 1 for each new line.
|
||||||
|
2. Never reuse a number, even if a line is deleted. Gaps are fine.
|
||||||
|
3. Lines within a single file have a contiguous prefix — `dock-worker_d_001` through `dock-worker_d_042`, etc.
|
||||||
|
4. Cross-file: `kael-davan.yaml` at the terminal and `kael-davan.yaml` at maintenance corridors both use the `kael-davan_d_###` namespace. Continue numbering from where the other file left off (check the existing files first, use a fresh sequence if the NPC is new to a location).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*D-084 — authored by Gestalt, Sprint 18*
|
||||||
@@ -144,7 +144,9 @@ NPCs may reference locations and entities beyond Sova Station. These are real bu
|
|||||||
|
|
||||||
**Other Krenn System stations:** The Krenn System has two other smaller orbital facilities (mining support station and an administrative relay). They're referenced occasionally in news tickers and operational scheduling. Not relevant to v0.1.
|
**Other Krenn System stations:** The Krenn System has two other smaller orbital facilities (mining support station and an administrative relay). They're referenced occasionally in news tickers and operational scheduling. Not relevant to v0.1.
|
||||||
|
|
||||||
**The horizon gate:** Sova Station has a connection to the Reach's horizon gate network — the interstellar transport infrastructure. The horizon gate terminal is in the Administrative Hub district, not the Transit District. Characters with legitimate need can book transit to other systems. This connection is what makes Sova relevant to a larger smuggling network; contraband doesn't originate in-system, it comes from elsewhere via horizon gate and moves through Sova's span gate to Velen.
|
**The Krenn Ring (horizon station):** The Krenn System's interstellar connection is the Krenn Ring — a horizon station at approximately 800 AU from the Krenn star, accessible by system vessel (~4–6 days from Station Sova). Horizon stations are ancient orbital installations of unknown origin, self-maintaining, each containing multiple gate apertures connecting to other star systems. The Krenn Ring is not on Station Sova; it is a separate installation in the outer system.
|
||||||
|
|
||||||
|
**The Administrative Hub's interstellar transit facility:** Sova Station's Administrative Hub district houses the transit processing facility for interstellar travel — customs clearance, booking offices, and the shuttle dock for vessels heading to the Krenn Ring. When NPCs or documents refer to "the horizon gate terminal," they mean this processing facility, not a gate aperture on the station itself. Characters with legitimate need book transit here, then travel by shuttle to the Krenn Ring to board. This connection is what makes Sova relevant to a larger smuggling network; contraband doesn't originate in-system, it comes from elsewhere via horizon gate and moves through Sova's span gate to Velen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# Spatial Layout: Gate Cluster (Span Gate Processing Facility)
|
||||||
|
|
||||||
|
**Ticket:** #153
|
||||||
|
**Date:** 2026-02-25
|
||||||
|
**Author:** Araminta (Visual Designer)
|
||||||
|
**Status:** v0.1 — wireframe quality, unblocks copy and gate cluster NPC authoring
|
||||||
|
|
||||||
|
**Grid:** 1 cell = 1m visual tile (visual grammar §2.1). Simulation operates at 0.5m; each visual tile = 2×2 sim tiles.
|
||||||
|
**Map area:** 40m wide × 32m deep (40×32 visual tiles) + observation gallery on z=2
|
||||||
|
**Zone palette:** Cool institutional grey — Era 3 construction, Commission-grade maintenance (visual grammar §1.1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spatial Character
|
||||||
|
|
||||||
|
The gate cluster is the newest structure in the Transit District. Era 3 construction: clean sightlines, uniform LED-white overhead lighting, minimal accumulated grime. Commission-monitored and Commission-maintained. Where the Terminal reads as institutional but worn, and the Bar as warm and accumulated, the gate cluster reads as **administered**. The architecture communicates that someone is watching.
|
||||||
|
|
||||||
|
The building is a funnel. The span gate aperture (~15–20m diameter ring) determines the widest point; the passenger and freight flows narrow through processing stages; they emerge into the gate concourse, which opens outward as public space. The spatial logic is deliberate: volume at intake, compression through customs, expansion at public exit.
|
||||||
|
|
||||||
|
Dual-use scheduling is the spatial and operational premise (D-095): freight windows and passenger windows share the single aperture. The "flicker" (90-second mode transition between sequences) is legible to experienced travelers — different lighting cues on the aperture chamber walls mark which mode is active.
|
||||||
|
|
||||||
|
**G-11 entry note:** The detective arrives via this cluster from a Commission shuttle. Workers arrive via the transit platform on the bar side. These are structurally separated entry vectors. The gate cluster is the detective's first experience of the district.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Floor Plan
|
||||||
|
|
||||||
|
### z=1 (Ground Floor)
|
||||||
|
|
||||||
|
```
|
||||||
|
N (span gate aperture — external, connects to The Ring)
|
||||||
|
|
|
||||||
|
1111111111222222222233333333334444444
|
||||||
|
1234567890123456789012345678901234567890
|
||||||
|
|
||||||
|
############[APERTURE RING]######### row 01 <- span gate ring (structural boundary)
|
||||||
|
# . . . APERTURE CHAMBER . . . # row 02
|
||||||
|
# . . . . . . . . . . . . . . # row 03 ACCESS: RESTRICTED
|
||||||
|
# . . . . . . . . . . . . . . # row 04 (airlock/transition zone)
|
||||||
|
########[D]##########[D]############ row 05 <- chamber exit doors (freight W, passenger E)
|
||||||
|
|
||||||
|
############################[D]###### row 06 <- freight staging north wall (east door = PAB)
|
||||||
|
# FREIGHT STAGING # PAB # row 07
|
||||||
|
# [FK][FK] [FK][FK] . # . # row 08 ACCESS: private (freight) / semi-public (PAB)
|
||||||
|
# [FK][FK] [FK][FK] . # . # row 09 PAB = Passenger Arrival Buffer
|
||||||
|
# [FK][FK] [FK][FK] . # . # row 10
|
||||||
|
# . . . . . . . . [CT][CT] # . # row 11 <- CT = cargo transporter dock points
|
||||||
|
# . . . . . . . . [CT][CT] # . # row 12 <- PAB merges south into customs at row 13
|
||||||
|
#####[D]####################[D]###### row 13 <- into customs lanes
|
||||||
|
|
||||||
|
###################[D]############### row 14 <- freight customs north entry
|
||||||
|
# FCL | FCL | FCL | FCL | FCL # row 15 ACCESS: semi-private (freight customs)
|
||||||
|
# [TS] | [TS] | [TS] | [TS] | [TS] # row 16 FCL = freight customs lane (5 lanes × 4vt)
|
||||||
|
# || | || | || | || | || # row 17 TS = terminal/scanner station per lane
|
||||||
|
# || | || | || | || | || # row 18 || = cargo lane (4vt wide, column breaks Q4)
|
||||||
|
# [P] | [P] | [P] | [P] | [P] # row 19 P = pillar/LOS anchor (4-tile interval)
|
||||||
|
# . . .|. . . |. . . |. . . |. . . # row 20 <- inspection floor
|
||||||
|
#######|#######[D]####[D]###|######## row 21 <- customs south wall; PCL entry
|
||||||
|
# PCL PCL PCL PCL PCL # row 22 ACCESS: semi-public (pedestrian customs)
|
||||||
|
# [TS] [TS] [TS] [TS] [TS] . . # row 23 PCL = pedestrian customs lanes (3 lanes × 2vt)
|
||||||
|
# [P] . . [P] . . [P] . . # row 24 <- queue markers + pillar anchors
|
||||||
|
# . . . . . . . . . . . . . . . # row 25
|
||||||
|
# . . . . . . . . . . . . . . . # row 26
|
||||||
|
############[D]####[D]############### row 27 <- customs south doors to concourse
|
||||||
|
|
||||||
|
#################################### row 28 <- concourse north wall
|
||||||
|
# . . [B] [B] . . [NT][NT] # row 29 ACCESS: public
|
||||||
|
# . . . . . . . . . . # row 30 B = bench, NT = news ticker
|
||||||
|
# . . [B] [B] . . . . . # row 31
|
||||||
|
# . . . . . . . . [D]SC # row 32 <- staircase (SC) east end; Commission entry
|
||||||
|
#################################### row 33 <- concourse south wall (district entry facade)
|
||||||
|
|
||||||
|
|
|
||||||
|
S (district interior — Terminal forecourt, transition corridor)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Legend:**
|
||||||
|
```
|
||||||
|
# Wall (solid, blocks LOS and movement)
|
||||||
|
. Open walkable floor
|
||||||
|
[D] Doorway (traversable)
|
||||||
|
[APERTURE RING] Span gate ring structure (impassable during transit; open between sequences)
|
||||||
|
FCL Freight customs lane
|
||||||
|
PCL Pedestrian customs lane
|
||||||
|
[TS] Terminal/scanner station (customs clerk workstation)
|
||||||
|
[FK] Freight staging kiosk / forwarder terminal
|
||||||
|
[CT] Cargo transporter dock point (loading/unloading position)
|
||||||
|
[B] Bench (public seating)
|
||||||
|
[NT] News ticker display (wall-mounted)
|
||||||
|
[P] Structural pillar (LOS anchor, column break, gallery support above)
|
||||||
|
SC Staircase to z=2 observation gallery (east end of concourse)
|
||||||
|
PAB Passenger Arrival Buffer (east of freight staging, rows 06–12)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### z=2 (Observation Gallery) — Commission-only
|
||||||
|
|
||||||
|
```
|
||||||
|
N
|
||||||
|
|
|
||||||
|
(above customs lanes — rows 14–27 below)
|
||||||
|
1111111111222222222233333333334444
|
||||||
|
1234567890123456789012345678901234567
|
||||||
|
|
||||||
|
[GALLERY NORTH RAIL — partial glass/grating]
|
||||||
|
################################# <- gallery west and east walls
|
||||||
|
# . . . . GALLERY FLOOR . . . # Commission-only: pristine near-white
|
||||||
|
# [DK][DK] . . . [DK][DK] # DK = observation desk / surveillance kit
|
||||||
|
# . . . . . . . . . . . . . . # floor: #d4d8dc
|
||||||
|
# . . . . . . . . . . . . . . # walls: #e0e4e8
|
||||||
|
# [DK][DK] . . . [DK][DK] #
|
||||||
|
# . . . . . . . . . . . . . . #
|
||||||
|
# . . . . . . . . . . . . . . #
|
||||||
|
# . . . . . . . . . . . . . . #
|
||||||
|
################################# <- gallery south rail (partial glass/grating)
|
||||||
|
|
|
||||||
|
[SC] staircase descends to z=1 concourse east end
|
||||||
|
|
|
||||||
|
S
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gallery dimensions:** 32m wide × 10m deep (32×10 visual tiles). Positioned above the customs lanes (z=1 rows 14–27) and NOT above the concourse or staging zones.
|
||||||
|
|
||||||
|
**Cross-z LOS:** Gallery rail is transparent low wall (glass or metal grating). Observer on z=2 has LOS downward to z=1 customs lanes. Upward LOS from z=1 is blocked except at the staircase opening. Players cannot see gallery occupants from the customs floor unless standing at the staircase.
|
||||||
|
|
||||||
|
**Gallery floor is the customs ceiling** — approximately 4m structural clearance below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zone Breakdown
|
||||||
|
|
||||||
|
### Zone 1 — Aperture Chamber (rows 01–05)
|
||||||
|
**Dimensions:** 40×4 visual tiles
|
||||||
|
**Access tier:** Restricted (Commission control + gate authority; no public entry)
|
||||||
|
**Purpose:** The transition space between the span gate aperture and the main processing facility. All passengers and freight pass through here immediately after emerging from the span gate. The aperture ring is the physical gate structure — when a transit sequence is active, the ring glows with transit residue (lighting cue for mode). Between sequences, the ring is dark and cold.
|
||||||
|
**NPC traffic:** Gate authority staff (2–3 stationed here per sequence). Arrivals flow through continuously during an active sequence; zero traffic between sequences.
|
||||||
|
**LOS notes:** The chamber is enclosed. No LOS to any other zone except the two exit doors (row 05). Gate authority staff can observe the full chamber volume. No LOS from the staging zones into the chamber.
|
||||||
|
**Key feature:** The "flicker" — the 90-second mode transition between freight and passenger sequences — is physically visible here. Lighting shifts, personnel rotate, cargo equipment is cleared or staged. An observer in the gate concourse (south) can hear the mode change but not see it.
|
||||||
|
|
||||||
|
### Zone 2 — Freight Staging (rows 06–13, west 24 tiles)
|
||||||
|
**Dimensions:** 24×8 visual tiles
|
||||||
|
**Access tier:** Private (authorized freight operators and customs personnel only)
|
||||||
|
**Purpose:** Where inbound freight is offloaded, registered, and staged for the customs inspection lanes. [FK] forwarder terminals are where freight agents log their manifest declarations before the cargo moves to the lanes. [CT] dock points are active during freight windows; they are dormant (low power, no staff) during passenger windows.
|
||||||
|
**NPC traffic:** Busy during freight windows. The operations manager (Triangle NPC) is typically here during active freight sequences — their role is coordinating flow from aperture to customs. Senior freight handlers work the dock points. Sparse during passenger windows.
|
||||||
|
**LOS notes:** Full LOS across the staging floor from the forwarder terminals. The freight customs entry door (row 13) is visible from the staging area. The PAB door (east) is visible but the PAB interior is not.
|
||||||
|
**Key feature:** The operations manager's position here — with LOS to the aperture chamber exits, the staging floor, and the customs entry — is the spatial expression of their authority. They see everything that comes in.
|
||||||
|
|
||||||
|
### Zone 3 — Passenger Arrival Buffer (rows 06–12, east 12 tiles)
|
||||||
|
**Dimensions:** 12×8 visual tiles
|
||||||
|
**Access tier:** Semi-public (arriving passengers only; no unauthorized entry from district side)
|
||||||
|
**Purpose:** Where passengers emerging from the span gate are held in a staging queue before processing through pedestrian customs. Separate from freight staging — the physical separation is the architectural enforcement of D-095's dual-use windows. During a freight window, the PAB is closed; during a passenger window, it fills.
|
||||||
|
**NPC traffic:** Moderated by gate sequence. Full during a passenger window; empty between or during freight windows.
|
||||||
|
**LOS notes:** LOS within the PAB is full. No direct LOS from the district concourse into the PAB — the customs lanes form a visual barrier. A player in the concourse sees only the south face of the customs lane structure.
|
||||||
|
**Key feature:** The detective's entry experience begins here (G-11). Arriving via Commission shuttle during a passenger window, they queue briefly before being waved through customs (or escorted directly to the observation gallery — see staircase at z=1 east end).
|
||||||
|
|
||||||
|
### Zone 4 — Freight Customs Lanes (rows 14–21, west 20 tiles)
|
||||||
|
**Dimensions:** 20×10 visual tiles (5 lanes × 4vt each, within a 20vt-wide zone, rows 14–21)
|
||||||
|
**Access tier:** Semi-private (freight operators entering the district; customs clerk staff)
|
||||||
|
**Purpose:** Processing incoming freight through customs inspection. Five lanes, each 4 visual tiles wide, each staffed by a customs clerk at a [TS] terminal station. [P] pillars at 4-tile intervals serve dual purpose: LOS anchors for the customs floor and structural supports for the observation gallery above.
|
||||||
|
**NPC traffic:** Customs clerks (5, one per lane) are stationed here during freight windows. The Commission inspector (Triangle NPC) circulates among lanes — their social dynamic with the clerks is expressed spatially by where they position themselves during inspections. During passenger windows, lanes are closed (screens down, no staff).
|
||||||
|
**LOS notes:** Clear LOS along each lane from north wall to south wall. The pillar breaks ([P]) interrupt cross-lane LOS at 4-tile intervals — an observer cannot see continuously across all 5 lanes. The gallery rail above (z=2) allows the Commission inspector to observe all lanes simultaneously from elevation. This is the critical asymmetry: floor-level observers have partial LOS; gallery observers have full LOS.
|
||||||
|
**Observation note:** The social triangle's power dynamic is visible in sightlines. The Commission inspector from the gallery sees the customs clerks in their entirety, including which freight forwarders are waved through vs. searched. The floor-level operations manager sees individual lanes but not the full picture. The detective, arriving from the gallery, can observe the customs floor before descending.
|
||||||
|
|
||||||
|
### Zone 5 — Pedestrian Customs Lanes (rows 22–27, east 12 tiles)
|
||||||
|
**Dimensions:** 12×10 visual tiles (3 lanes × 2vt each, with queue space to east, rows 22–27)
|
||||||
|
**Access tier:** Semi-public (arriving passengers processing into the district)
|
||||||
|
**Purpose:** Processing arriving passengers through customs. Three lanes, each 2 visual tiles wide, each with a [TS] scanner station. Queue space runs east of the lane structure. Simpler operation than freight customs — personal items scan, biometric check, manifest tag if applicable.
|
||||||
|
**NPC traffic:** Active only during passenger windows. During freight windows, the customs clerks from PCL rotate to assist with FCL overflow. The Commission inspector may operate from PCL during passenger windows if intelligence suggests surveillance value.
|
||||||
|
**LOS notes:** Narrower lanes mean LOS is more constrained. An observer in the queue can see only the lane directly ahead. From the gate concourse (south), the south face of the customs structure presents as a low partition — passengers emerging from customs are visible from the concourse immediately on exit.
|
||||||
|
**Key feature:** This is where observable inequity happens. The Commission inspector (or a directive they issue) results in one class of traveler being waved through while another is searched. This is visible to anyone in the concourse queue area — including the detective. The spatial proximity of the PCL south wall to the concourse benches [B] means concourse passengers witness the processing of arrivals.
|
||||||
|
|
||||||
|
### Zone 6 — Gate Concourse (rows 28–33)
|
||||||
|
**Dimensions:** 40×8 visual tiles (full building width)
|
||||||
|
**Access tier:** Public (all district residents, workers, and new arrivals)
|
||||||
|
**Purpose:** The public-facing terminus of the gate cluster. Benches [B] for waiting passengers, news ticker [NT] on the east wall for transit schedules and general news, and the primary facade opening to the district south. The staircase (SC) at the east end is the access point to the observation gallery — it is Commission-coded at the base (a discreet panel, not a visible barrier).
|
||||||
|
**NPC traffic:** Variable. Busy when a passenger sequence has just completed (arrivals dispersing). Sparse during freight windows (only workers and officials). The concourse is the natural convergence zone for all district-side personnel who have business at the gate cluster.
|
||||||
|
**LOS notes:** Full east-west LOS across the concourse. The [P] pillars from the customs lanes above do not extend to the concourse floor — the south edge of the customs structure is a visual wall at row 27. From the benches, observers can see the customs exit doors (row 27) and watch arrivals emerge. Cannot see into customs lanes from bench positions.
|
||||||
|
**Key feature:** The social reading zone on arrival. New arrivals (including the detective on their first visit) experience the concourse before moving into the district. The news ticker is a topic generator. The Commission staircase (east end) is present but low-key — coded access does not broadcast itself in Commission-grade facilities.
|
||||||
|
|
||||||
|
### Zone 7 — Observation Gallery (z=2, above zones 4–5)
|
||||||
|
**Dimensions:** 32×10 visual tiles (above the full customs lane section)
|
||||||
|
**Access tier:** Commission-only (staircase coded at z=1 east end of concourse)
|
||||||
|
**Purpose:** The gallery is where the Commission inspector works during active processing sequences. From here, all freight and pedestrian customs lanes are simultaneously observable. Observation desks [DK] with surveillance kit allow real-time customs monitoring, camera feed access, and communication with gate authority staff below. This is the institutional oversight position — the spatial embodiment of Commission authority over district entry.
|
||||||
|
**NPC traffic:** The Commission inspector during work hours. Possibly a second Commission observer (junior) — but sparse. This is not a social space; it is a surveillance position.
|
||||||
|
**LOS notes:** Full LOS down to all customs lanes (z=2 → z=1, through gallery rail). Partial LOS to freight staging (row 13 door visible from gallery north rail). NO LOS to aperture chamber (wall blocks), NO LOS to concourse (gallery south rail is opaque below rail height). Gallery interior has no LOS from the customs floor below — the cross-z asymmetry is deliberate and complete.
|
||||||
|
**Key feature:** The detective's first introduction to this space is via escort through the staircase. The experience of descending from gallery (full picture) to concourse (partial picture) is the spatial tutorial for the information asymmetry theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Observation Positions
|
||||||
|
|
||||||
|
| Position | Code | LOS coverage | Why it matters |
|
||||||
|
|----------|------|-------------|----------------|
|
||||||
|
| Observation gallery (z=2, center) | POS-G1 | All freight + pedestrian customs lanes simultaneously | Commission inspector's domain. Highest-information position in the gate cluster. Asymmetric — not visible from below. |
|
||||||
|
| Freight staging floor (center) | POS-G2 | Aperture chamber exits, freight staging, customs entry door | Operations manager's natural position. Sees intake and output but not gallery or pedestrian lanes. |
|
||||||
|
| Gate concourse benches (rows 29-30, west) | POS-G3 | Customs exit doors (row 27), staircase base (east), full concourse | Player's first investigation position. Passive observation of arrivals emerging from customs and who accesses the staircase. |
|
||||||
|
| Pedestrian customs queue (row 22, east) | POS-G4 | PCL lanes, customs exit direction | Observer in queue can watch the customs clerks processing arrivals. Visible inequity in who is waved through vs. searched. |
|
||||||
|
| Concourse east end (near staircase) | POS-G5 | Staircase access panel, anyone ascending/descending | Monitoring staircase access reveals Commission movement. Coded panel is discreet but observable. |
|
||||||
|
| Gallery north rail (z=2) | POS-G6 | Freight staging floor through rail, customs lane north entries | Extended north-viewing position from gallery — tracks cargo from aperture exit to lane entry. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sightline Analysis
|
||||||
|
|
||||||
|
```
|
||||||
|
FROM → Aperture Frt.Stg. PAB Frt.Cust. Ped.Cust. Concourse Gallery
|
||||||
|
TO ↓
|
||||||
|
Aperture SELF via door via door NO NO NO NO
|
||||||
|
Frt.Staging via door SELF via door via door NO NO NO
|
||||||
|
PAB via door via door SELF NO NO NO NO
|
||||||
|
Frt.Customs NO via door NO SELF NO via door rail(z2→z1)
|
||||||
|
Ped.Customs NO NO NO NO SELF via door rail(z2→z1)
|
||||||
|
Concourse NO NO NO via door via door SELF NO
|
||||||
|
Gallery NO rail(N) NO rail(full) rail(full) NO SELF
|
||||||
|
|
||||||
|
rail(z2→z1) = LOS from gallery down through transparent rail/grating
|
||||||
|
rail(N) = gallery north rail has partial LOS to freight staging floor
|
||||||
|
NO = wall or z-gap blocks
|
||||||
|
via door = LOS when door open
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical sightline: Gallery → all customs lanes**
|
||||||
|
The Commission inspector on z=2 has full LOS over every freight and pedestrian customs lane simultaneously. No position on the z=1 customs floor achieves equivalent coverage. This asymmetry is the spatial expression of institutional oversight.
|
||||||
|
|
||||||
|
**Critical sightline gap: Concourse → customs interior**
|
||||||
|
The concourse benches are south of the customs structure. The customs south wall (rows 14–21 for freight, 22–27 for pedestrian) presents as a visual barrier. A player on the benches sees the customs exit doors and emerging arrivals — but not what happens inside the lanes. Investigation of customs behavior requires entering the lanes or reaching the gallery.
|
||||||
|
|
||||||
|
**Critical sightline gap: Gallery → concourse**
|
||||||
|
The gallery south rail is opaque below the rail height. The Commission inspector cannot observe the concourse from the gallery without descending. The gallery is a surveillance position for entry processing, not for the public space.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Access Tier Map
|
||||||
|
|
||||||
|
```
|
||||||
|
RESTRICTED PRIVATE SEMI-PRIVATE SEMI-PUBLIC PUBLIC
|
||||||
|
────────── ─────── ──────────── ─────────── ──────
|
||||||
|
Aperture Freight staging Freight customs Ped. customs Concourse
|
||||||
|
chamber (auth. operators) lanes lanes (all)
|
||||||
|
(clerks + (arriving
|
||||||
|
[Gallery z=2: freight ops) passengers)
|
||||||
|
Commission-only]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sequential access enforcement (H-06): A freight operator moving from aperture to district must pass through freight staging → freight customs → concourse. No spatial path skips a tier. Pedestrian arrivals pass through PAB → pedestrian customs → concourse. The two flows are physically separated (west half vs. east half of the building) and join only at the concourse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NPC Traffic Density Annotations
|
||||||
|
|
||||||
|
| Time | Aperture | Frt. Staging | PAB | Frt. Customs | Ped. Customs | Concourse | Gallery |
|
||||||
|
|------|----------|-------------|-----|-------------|-------------|-----------|---------|
|
||||||
|
| Dawn (05-07) | very sparse | very sparse | closed | closed | closed | very sparse | — |
|
||||||
|
| Freight window 1 (07-12) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
|
||||||
|
| Passenger window (12-14) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
|
||||||
|
| Freight window 2 (14-19) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
|
||||||
|
| Passenger window (19-20) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
|
||||||
|
| Evening sparse (20-23) | sparse | sparse | closed | sparse | closed | sparse | varies |
|
||||||
|
| Night (23-05) | very sparse | very sparse | closed | closed | closed | very sparse | — |
|
||||||
|
|
||||||
|
**Flicker windows:** 90-second transition between freight and passenger modes. During this window:
|
||||||
|
- Aperture chamber resets (personnel exchange, lighting shifts, cargo equipment cleared or staged)
|
||||||
|
- All customs lanes briefly closed
|
||||||
|
- Concourse becomes transiently busier as travelers waiting for mode completion gather
|
||||||
|
- The operations manager is most exposed — coordinating the reset, moving between staging and customs entry
|
||||||
|
|
||||||
|
**Detective entry:** Commission shuttles arrive during passenger windows as a matter of protocol. First contact with the district begins in the aperture chamber, proceeds to the PAB, and typically diverts to the gallery staircase before customs processing is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Narrative Triangle Service Notes
|
||||||
|
|
||||||
|
### Triangle 5 — Gate Authority (Operations Manager – Senior Freight Handler – Commission Inspector)
|
||||||
|
|
||||||
|
This is an institutional-oversight triangle, structurally different from the Terminal's knowledge-and-leverage triangles (1–2) and the Bar's social-loyalty triangles (3–4).
|
||||||
|
|
||||||
|
- **Operations manager:** Their domain is the freight flow — aperture to staging to customs. They have private-tier access everywhere on the z=1 floor. They are measured by throughput: how much cargo clears customs in a window. They have an accommodation relationship with certain freight forwarders (see customs inequity below).
|
||||||
|
- **Senior freight handler:** The forwarder who benefits from that accommodation. They know what they get, they know why, and they know the operations manager knows they know. This is the stable complicity leg of the triangle.
|
||||||
|
- **Commission inspector:** Their domain is the gallery. They watch the customs floor from above. They may know about the accommodation, or may be about to discover it, or may be using it as leverage already. Their relationship to the operations manager is formally collaborative, actually adversarial.
|
||||||
|
|
||||||
|
**Spatial expression:** The operations manager never goes to the gallery. The Commission inspector rarely comes to the floor. The senior freight handler is on the floor. The triangle's tension is mediated by the cross-z sightline — the inspector can see the forwarder being waved through, and the operations manager knows the inspector is watching, but neither will acknowledge it in the same zone at the same time.
|
||||||
|
|
||||||
|
**Observable inequity (investigation entry point):** A player watching from the concourse benches (POS-G3) or from the pedestrian customs queue (POS-G4) can observe a freight forwarder being waved through freight customs without search while a commuter on the pedestrian side receives a full scan. This is not dramatic — it reads as normal. The player has to make the connection: waved through = known cargo = manifested incorrectly = this is where the lattice components enter.
|
||||||
|
|
||||||
|
**Tension staging locations:**
|
||||||
|
- Freight staging floor (operations manager's ground; conversations here are authority-neutral)
|
||||||
|
- Gallery (inspector's ground; a summons to the gallery is pressure)
|
||||||
|
- Customs lane (the observable action space; what clerks actually do is determined by unspoken directives from above)
|
||||||
|
- Concourse east end near staircase (the transition space; anyone ascending the staircase must pass anyone watching the staircase)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z-Level Notes
|
||||||
|
|
||||||
|
**z=0:** Not present in the gate cluster. Maintenance access to the gate cluster, if any, is via the district maintenance spine (z=0 elsewhere in district) and does not extend into the gate cluster interior. Era 3 construction has no maintenance corridor integration — maintenance occurs from above via service panels.
|
||||||
|
|
||||||
|
**z=1:** All gate cluster zones: aperture chamber, freight staging, PAB, freight customs, pedestrian customs, concourse. All NPCs and player movement on this level.
|
||||||
|
|
||||||
|
**z=2:** Observation gallery only. Staircase is the sole connection point (z=1 concourse east end ↔ z=2 gallery). Commission-coded access panel at base of staircase is present but low-profile (no visible lock, no visible panel labeling in Era 3 style — access is granted by insertion of Commission neural-tag proximity, not a key).
|
||||||
|
|
||||||
|
**Cross-z visibility rules:**
|
||||||
|
- Gallery → customs lanes: full downward LOS through transparent rail/grating
|
||||||
|
- Customs lanes → gallery: no upward LOS (gallery floor solid except rail; rail height above standing head height)
|
||||||
|
- Staircase opening: local LOS only (see who is at the staircase base or top, not into gallery interior)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Copy Team
|
||||||
|
|
||||||
|
1. **Aperture chamber is the in-world ritual.** Arriving via span gate is Commonwealth-mundane but the aperture ring has residual energy effects — ambient hum, slight color temperature shift as light normalizes from transit. Monologue lines for the detective's arrival should note this. Standard sensory detail for immersive-world arrivals.
|
||||||
|
2. **The customs inequity is not dramatic.** When the senior freight handler is waved through, it should read as routine from NPC behavior — a nod, a scan confirmed, the lane opens. Overheard dialogue, if any, should be procedural: manifest check language, not conversational. The player learns that something is wrong from the pattern, not from a flagrant scene.
|
||||||
|
3. **The Commission inspector's gallery is their professional comfort zone.** Dialogue set in the gallery (if the detective accesses it) should reflect this — the inspector is at ease up here, slightly less guarded. On the floor, they are performing authority. In the gallery, they are just watching.
|
||||||
|
4. **The operations manager on the freight staging floor.** This is their element. Logistics language, shorthand with the senior freight handler. Any casual conversation with the detective here is the operations manager on home turf — helpful enough, not forthcoming.
|
||||||
|
5. **The flicker is an ambient event.** Travelers who know the schedule stop and wait. Travelers who don't know find themselves in a 90-second limbo — nothing is moving, customs is closed. The concourse fills briefly. Use this as a social compression beat: forced proximity, idle waiting, overheard conversations that wouldn't happen mid-flow.
|
||||||
|
6. **The staircase is visible, not obvious.** In Era 3 design language, it is clean, architectural, slightly more refined than the surrounding fittings. It doesn't broadcast Commission. Players who are paying attention will notice it; players who aren't will miss it. Second visit = "wait, I didn't see that last time."
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
# Tier 1 Drama Module — Authoring Guide
|
||||||
|
|
||||||
|
**Schema:** `content/schemas/drama_module.schema.yaml`
|
||||||
|
**Module pool:** `content/modules/tier1/*.yaml`
|
||||||
|
**Decisions:** D-023 (three-tier model), D-027 (vertical slice), D-029 (30/50/20 population), D-034 (FRIEND pattern)
|
||||||
|
**Vertical slice reference:** `content/modules/tier1/smuggling_ring_v0_1.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is a Tier 1 Drama Module?
|
||||||
|
|
||||||
|
Tier 1 is the authored conspiracy layer of D-023. Drama modules are the things that can go wrong — or go very right, or simply happen — beneath the surface of daily life in Sova Transit. They are:
|
||||||
|
|
||||||
|
- **Hand-authored.** Every event sequence, every NPC role, every outcome was written by a person.
|
||||||
|
- **Pool-based.** Multiple modules exist. The storyteller draws from the pool at game start and activates a subset based on the district and the storyteller's pacing decisions.
|
||||||
|
- **Optional from the player's perspective.** The player can play 60 minutes without engaging the ring. The ring happens anyway. D-027 criterion #4: the observe→notice→follow→discover sequence must emerge from *systems*, not *scripts*.
|
||||||
|
- **Dual-lens.** Every module must be experienced differently by the smuggler and detective characters. Same world, different keyholes.
|
||||||
|
|
||||||
|
What they are **not:**
|
||||||
|
- Not quests with markers or objectives.
|
||||||
|
- Not scripted cutscenes.
|
||||||
|
- Not balanced challenge encounters.
|
||||||
|
|
||||||
|
The storyteller uses the module as a *schedule* — a series of world events it will fire, and conditions it monitors to determine how the world resolves. The player is a witness and agent in a world that moves with or without them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
schemas/
|
||||||
|
drama_module.schema.yaml ← Schema reference (this file validates against it)
|
||||||
|
modules/
|
||||||
|
tier1/
|
||||||
|
smuggling_ring_v0_1.yaml ← The v0.1 vertical slice module
|
||||||
|
future_module_v0_1.yaml ← Future modules go here
|
||||||
|
```
|
||||||
|
|
||||||
|
One `.yaml` file per drama module. The storyteller's content loader scans `content/modules/tier1/` at startup and adds all valid modules to the pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Reference
|
||||||
|
|
||||||
|
### Identity Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `module_id` | Yes | Stable slug: `{name}_v{major}_{minor}`. Never reuse. Increment on breaking structural change. |
|
||||||
|
| `display_name` | Yes | Human-readable title for dev tooling. Not shown in-game. |
|
||||||
|
| `version` | Yes | Authoring version: `{major}.{minor}`. |
|
||||||
|
| `tier` | Yes | Always `1`. |
|
||||||
|
| `description` | No | One-paragraph design summary. Authoring-only. |
|
||||||
|
| `notes` | No | Design rationale, cross-references. Ignored at load time. |
|
||||||
|
| `dual_lens` | No | How smuggler vs detective experience this module. Authoring-only. **Write this first** — it disciplines the design. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pool Metadata
|
||||||
|
|
||||||
|
Controls how the storyteller samples this module.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `pool.weight` | Yes | Selection probability 1–10. Higher = more likely per playthrough. Default 5. |
|
||||||
|
| `pool.compatible_districts` | No | District slugs. Omit for "any". |
|
||||||
|
| `pool.incompatible_with` | No | Module IDs that can't run concurrently. |
|
||||||
|
| `pool.max_concurrent` | No | Almost always 1. |
|
||||||
|
|
||||||
|
**Design note on weight:** Use weight to tune narrative variety, not difficulty. A weight-1 module is a rare playthrough surprise. A weight-8 module like the smuggling ring is "this is usually what's happening in Sova Transit."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Entry Conditions
|
||||||
|
|
||||||
|
Defines when the module becomes eligible for activation. ALL world-state conditions must be true. The activation trigger determines *how* it fires.
|
||||||
|
|
||||||
|
#### World-State Condition Types
|
||||||
|
|
||||||
|
| Type | Required Fields | Use When |
|
||||||
|
|------|----------------|----------|
|
||||||
|
| `npc_present` | `role` | The module requires a specific NPC to be in the district. |
|
||||||
|
| `location_accessible` | `location` | The module requires a location the player can physically reach. |
|
||||||
|
| `fact_not_known` | `fact_id` | Module shouldn't activate if a precondition has already been discovered. |
|
||||||
|
| `no_active_module` | `module_id` | Prevents two incompatible modules running at once. |
|
||||||
|
| `fact_known` | `fact_id`, `known_by` | Module requires prior knowledge to make sense. |
|
||||||
|
|
||||||
|
#### Player Conditions (Optional)
|
||||||
|
|
||||||
|
Player conditions are *optional* — modules can and should activate without player engagement as a prerequisite. Use player conditions sparingly, only when the module literally cannot function without a minimum relationship state.
|
||||||
|
|
||||||
|
#### Activation Triggers
|
||||||
|
|
||||||
|
| Trigger | When to Use |
|
||||||
|
|---------|-------------|
|
||||||
|
| `storyteller_push` | Default. Storyteller activates on its own pacing. Most Tier 1 modules. |
|
||||||
|
| `proximity` | Module activates when player wanders near a key location. Useful for "stumble-upon" conspiracies. |
|
||||||
|
| `player_action` | Reserved for modules that require player initiation. Use rarely. |
|
||||||
|
|
||||||
|
**The `min_play_ticks` field is load-bearing for D-027 criterion #1.** At approximately 1 tick/second, 30 minutes of play ≈ 1800 ticks. Set `min_play_ticks` to at least 1800. The vertical slice uses 2100 to give extra breathing room.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### NPC Requirements
|
||||||
|
|
||||||
|
Each module specifies its NPC slots. Roles are internal slugs used throughout the rest of the document.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `role` | Yes | Module-internal slug. Kebab-case. Used in event triggers and outcome conditions. |
|
||||||
|
| `display_hint` | No | Authoring note: who this role is narratively. |
|
||||||
|
| `binding` | Yes | `named` (specific authored NPC) or `generated` (any matching NPC). |
|
||||||
|
| `named_npc` | Conditional | Required when `binding: named`. Short-form canonical ID: `npc:{slug}`. |
|
||||||
|
| `axes` | Conditional | Required when `binding: generated`. Axis constraints the NPC must satisfy. |
|
||||||
|
| `must_have_pattern` | No | Optional NPC pattern (D-024 System A). |
|
||||||
|
| `must_have_motivation` | No | Optional NPC motivation (D-024 System B). |
|
||||||
|
| `is_optional` | No | Default false. If true, module runs without this slot filled (degraded experience). |
|
||||||
|
|
||||||
|
#### Named vs. Generated Bindings
|
||||||
|
|
||||||
|
**Named bindings** reference specific hand-authored NPCs from the district. All v0.1 roles are named. This is the right choice for:
|
||||||
|
- THE FRIEND NPCs (D-034) — they have authored arcs, not generic behavior
|
||||||
|
- NPCs with unique relationships in the 5-triangle web
|
||||||
|
- Roles where voice, history, and moral weight matter
|
||||||
|
|
||||||
|
**Generated bindings** are for future modules set in different districts or using procedurally generated NPCs. They use axis constraints:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
axes:
|
||||||
|
- axis: secret
|
||||||
|
constraint: has_major_secret
|
||||||
|
- axis: contentment
|
||||||
|
constraint: min_contentment_-3 # Discontented, susceptible to opportunity
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraint conventions: `has_{value}`, `min_{N}`, `not_{value}`. The server's NPC filter system interprets these.
|
||||||
|
|
||||||
|
#### What "Roles" Are Not
|
||||||
|
|
||||||
|
NPC roles in a drama module are **not** the same as NPC patterns (FRIEND, MIRROR, etc.) or motivations (HANDLER, WITNESS, etc.). Module roles are:
|
||||||
|
- Functional slots within the module's narrative (ring-leader, witness, evidence-holder)
|
||||||
|
- Module-local: "ring-leader" in the smuggling ring module ≠ "ring-leader" in any other module
|
||||||
|
- Used to reference the same NPC across events and outcomes without hardcoding the NPC slug
|
||||||
|
|
||||||
|
#### NPC Pattern and Motivation Reference
|
||||||
|
|
||||||
|
Patterns (System A, `must_have_pattern`) encode the NPC's thematic function in the player's experience:
|
||||||
|
|
||||||
|
| Pattern | What It Means |
|
||||||
|
|---------|---------------|
|
||||||
|
| `FRIEND` | Emotionally complex anchor; the contradiction arc lives here (D-034) |
|
||||||
|
| `MIRROR` | Reflects the player character's own path back at them |
|
||||||
|
| `ANCHOR` | Reliable presence; stability the player can always return to |
|
||||||
|
| `GHOST` | Presence felt more than seen; past hangs over current events |
|
||||||
|
| `CATALYST` | Actions cause cascading effects on other NPCs |
|
||||||
|
| `THRESHOLD` | Gatekeeper; controls access to deeper information or relationships |
|
||||||
|
| `REMNANT` | Survivor of a prior event; carries knowledge others want buried |
|
||||||
|
| `SYSTEM` | Embodies an institution or faction rather than personal stakes |
|
||||||
|
| `NOBODY` | Genuinely flat; texture and atmosphere, no arc |
|
||||||
|
|
||||||
|
Motivations (System B, `must_have_motivation`) encode why the NPC acts within the module's conspiracy:
|
||||||
|
|
||||||
|
| Motivation | What It Means |
|
||||||
|
|------------|---------------|
|
||||||
|
| `HANDLER` | Organizes or directs others; the operational center |
|
||||||
|
| `WITNESS` | Knows something they haven't decided to act on |
|
||||||
|
| `TURNCOAT` | Wants out, or has already switched allegiance |
|
||||||
|
| `CIVILIAN` | No conspiracy involvement; proximity creates moral weight |
|
||||||
|
| `OPERATOR` | Executes tasks; functional cog in the system |
|
||||||
|
| `SKEPTIC` | Doubts the conspiracy exists; useful foil for investigation |
|
||||||
|
|
||||||
|
**Full definitions and canonical usage:** `decisions/content.md` D-024.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
Events are world-state changes the storyteller fires. They are not scripted player experiences — they happen in the world, and the player may or may not observe them.
|
||||||
|
|
||||||
|
#### Sequences vs. Pools
|
||||||
|
|
||||||
|
| Structure | Use For |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Sequence** | Ordered narrative beats. Step N+1 becomes eligible only after step N fires. Use for character arcs. |
|
||||||
|
| **Pool** | Unordered ambient activity. The storyteller fires any eligible event at any time. Use for texture and background. |
|
||||||
|
|
||||||
|
The vertical slice uses:
|
||||||
|
- `kael_exit_arc` (sequence) — Kael's ordered character arc
|
||||||
|
- `investigation_pressure` (sequence) — Parallel pressure escalation
|
||||||
|
- `ambient_ring_activity` (pool) — Background ring business that runs throughout
|
||||||
|
|
||||||
|
Most modules should have 1-2 sequences plus 1 pool.
|
||||||
|
|
||||||
|
#### Event Step Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `event_id` | Yes | Unique within module. Used in outcome conditions and `ticks_since_event` triggers. |
|
||||||
|
| `label` | No | Short human-readable label for dev tooling. |
|
||||||
|
| `description` | No | What happens narratively. Write this first — events should have a clear observable presence. |
|
||||||
|
| `triggers` | Yes | ANY trigger being true fires the event. Multiple triggers = OR logic. |
|
||||||
|
| `effects` | No | What changes in the world. |
|
||||||
|
| `once` | No | Default `true`. Set `false` for repeating events (ambient discrepancies, etc.). |
|
||||||
|
| `sets_flag` | No | Module-internal flag set when event fires. Used in outcome conditions. |
|
||||||
|
|
||||||
|
#### Trigger Types
|
||||||
|
|
||||||
|
| Type | Fires When | Key Fields |
|
||||||
|
|------|-----------|------------|
|
||||||
|
| `ticks_since_activation` | N ticks after module activated | `ticks` |
|
||||||
|
| `ticks_since_event` | N ticks after a previous event fired | `after_event`, `ticks` |
|
||||||
|
| `player_proximity` | Player near NPC/location | `target_type`, `target`, `radius_tiles` |
|
||||||
|
| `player_action` | Player interacts with target | `action`, `target_role` |
|
||||||
|
| `fact_known_by_player` | Player has discovered a fact | `fact_id` |
|
||||||
|
| `flag_set` | A module flag has been set | `flag` |
|
||||||
|
| `npc_mood` | NPC enters a mood state | `npc_role`, `mood` |
|
||||||
|
|
||||||
|
**Design principle: events should fire without the player.** Every event must have at least one tick-based trigger (`ticks_since_activation` or `ticks_since_event`). Proximity and action triggers are secondary paths that fire the event *earlier* if the player engages. The world moves at its own pace; the player accelerates or delays, not controls.
|
||||||
|
|
||||||
|
#### Effect Types
|
||||||
|
|
||||||
|
| Type | Use For |
|
||||||
|
|------|---------|
|
||||||
|
| `npc_routine_deviation` | Visible NPC behavior change. Write this descriptively — it's what the player sees. |
|
||||||
|
| `fact_becomes_discoverable` | Gates a fact into the knowledge graph at Rumoured confidence. |
|
||||||
|
| `tell_intensify` | NPC's tell behavior becomes more frequent/pronounced. |
|
||||||
|
| `flag_set` | Internal state tracking. Not visible to player. |
|
||||||
|
| `location_state` | Something visible changes in a location. |
|
||||||
|
| `npc_knowledge_update` | An NPC learns something new. |
|
||||||
|
|
||||||
|
**On `fact_becomes_discoverable`:** This makes a fact discoverable, not known. The player still has to find it — through proximity, examination, dialogue, or observation. The `discovery_method` field is an authoring note for how: be specific enough that a Mellanie can write the dialogue or monologue that surfaces it, and a Gestalt can define the trigger condition in the fact catalog.
|
||||||
|
|
||||||
|
**Fact ID convention:** Use `{module-slug}.{fact_name}` — e.g., `ring.kael_unauthorized_corridor_access`. The module slug prefix namespaces the fact to avoid collisions across modules. Before creating a new fact ID, check `content/global/knowledge/` to see if an equivalent fact already exists; reuse it rather than creating a duplicate.
|
||||||
|
|
||||||
|
**Mapping `discovery_method` to D-035 trigger types:** The `discovery_method` note should describe exactly how the player triggers fact discovery. This maps directly to the D-035 monologue trigger taxonomy (full list in `decisions/content.md` D-035 and `content/global/enums/triggers.yaml`):
|
||||||
|
|
||||||
|
| If discovery happens via… | D-035 trigger type | What to author |
|
||||||
|
|--------------------------|-------------------|----------------|
|
||||||
|
| Player enters the location where something is visible | `enter_location` | Monologue line flagging the anomaly on arrival |
|
||||||
|
| Player watches an NPC doing something unusual | `observe_npc` | Monologue line on NPC observation; dialogue option unlocks |
|
||||||
|
| Player examines an object or terminal | `observe_anomaly` | Examine verb interaction; monologue on result |
|
||||||
|
| Player witnesses two NPCs interacting | `witness_interaction` | Monologue line; trust-gated gossip unlock |
|
||||||
|
| Player finishes a conversation with the relevant NPC | `post_conversation` | Monologue beat after talking to the NPC |
|
||||||
|
| Player discovers a physical object (cargo, message) | `discover_evidence` | Examine verb; monologue on discovery |
|
||||||
|
| Player returns to a location they've been before | `return_visit` | Monologue on changed state vs. prior visit |
|
||||||
|
|
||||||
|
Write the `discovery_method` note to specify which of these applies — ideally two methods for redundancy (e.g., `enter_location` plus `observe_anomaly`) so players aren't funneled into a single approach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Outcomes
|
||||||
|
|
||||||
|
Outcomes are resolution states. The storyteller checks all outcome conditions each tick after the module activates. The first matching outcome is applied.
|
||||||
|
|
||||||
|
**Every module must include:**
|
||||||
|
- At least one terminal outcome that represents "the investigation succeeded"
|
||||||
|
- At least one terminal outcome that represents "the conspiracy ran its course"
|
||||||
|
- Exactly one expiry outcome (`is_expiry: true`) for quiet player non-engagement
|
||||||
|
|
||||||
|
#### Outcome Fields
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `outcome_id` | Yes | Unique slug. |
|
||||||
|
| `label` | Yes | Short label. |
|
||||||
|
| `is_terminal` | Yes | `true` = module ends. `false` = transitional state (module can continue evolving). |
|
||||||
|
| `is_expiry` | No | `true` = this is the quiet-exit outcome. One per module. |
|
||||||
|
| `conditions` | No | ALL conditions must be true. See below. |
|
||||||
|
| `effects` | No | World changes when outcome is reached. |
|
||||||
|
|
||||||
|
**On `is_terminal: false`:** A non-terminal outcome fires its effects and applies its label, but the module remains active — the storyteller keeps checking for the next matching outcome. Use this for intermediate states where the world has visibly shifted but the situation hasn't resolved: the `ring_splinters` outcome in the vertical slice is non-terminal because the ring going quiet is a change of state, not a conclusion. A module with only non-terminal outcomes will run forever; always ensure there is a reachable terminal outcome (or expiry) downstream.
|
||||||
|
|
||||||
|
#### Outcome Conditions
|
||||||
|
|
||||||
|
| Condition | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `facts_known` | Player must know all listed facts. |
|
||||||
|
| `facts_not_known` | Player must NOT know any listed facts. |
|
||||||
|
| `flags_set` | All listed module flags must be set. |
|
||||||
|
| `flags_not_set` | None of listed flags may be set. |
|
||||||
|
| `events_fired` | All listed events must have fired. |
|
||||||
|
| `ticks_since_activation` | Module has been running for at least N ticks. |
|
||||||
|
|
||||||
|
#### Outcome Effects
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `npc_disposition` | NPC's relationship state with player shifts. |
|
||||||
|
| `faction_reaction` | Faction reputation change. |
|
||||||
|
| `location_access_change` | Location becomes restricted, locked, or open. |
|
||||||
|
| `fact_state` | Fact is permanently known, hidden, or destroyed. |
|
||||||
|
| `npc_exit` | NPC leaves the district or becomes inaccessible. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles for Tier 1 Modules
|
||||||
|
|
||||||
|
### 1. The World Moves First
|
||||||
|
|
||||||
|
Events happen on a tick schedule. The player is a witness who can accelerate, delay, or redirect — not a trigger. If your module can only function if the player takes specific actions, it's a quest, not a drama module.
|
||||||
|
|
||||||
|
### 2. Both Characters Must Have a Story
|
||||||
|
|
||||||
|
Every event and outcome must mean something different to the smuggler and the detective. Write the `dual_lens` authoring field first. If you can't write both lenses, the module is character-agnostic filler — not Tier 1.
|
||||||
|
|
||||||
|
### 3. No Clean Resolutions
|
||||||
|
|
||||||
|
D-034 and D-027 both require moral ambiguity. The smuggling ring doesn't have a "good" ending. The detective arresting Kael is not obviously better than letting him go. Every outcome must have a cost. If one outcome is obviously correct, you've failed the design.
|
||||||
|
|
||||||
|
### 4. THE FRIEND Contradiction Is the Pivot
|
||||||
|
|
||||||
|
If your module involves a FRIEND-pattern NPC, the observable contradiction (D-034) must be:
|
||||||
|
- **Observable from spatial positioning** — not from dialogue, not from menus
|
||||||
|
- **Ambiguous before context** — the player sees the behavior before they understand what it means
|
||||||
|
- **Irreversible once witnessed** — seeing changes the relationship, even if the player does nothing
|
||||||
|
|
||||||
|
The secret meeting in corridor B-7 is the canonical example. After witnessing it, neither character can pretend they don't know what they saw.
|
||||||
|
|
||||||
|
### 5. Expiry Is Not Failure
|
||||||
|
|
||||||
|
The `module_abandoned` expiry outcome should feel like a natural ending, not a penalty. The world closes around this conspiracy without the player. That's the 70% mundane reality (D-029): most conspiracies don't get protagonists. Write the expiry description to feel melancholy but not punitive.
|
||||||
|
|
||||||
|
### 6. Facts, Not Flags, Drive Investigation
|
||||||
|
|
||||||
|
Facts (from `global/knowledge/`) are the player's knowledge graph. Flags are the storyteller's internal state tracking. The key design question: "Is this something the player knows, or is this something the storyteller tracks?" If the player knows it, it's a fact. If the storyteller tracks it, it's a flag.
|
||||||
|
|
||||||
|
Facts should be discoverable through multiple methods (observation, dialogue, examination, proximity). Never require a single specific action to surface a critical fact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation and Format Rules (Gestalt)
|
||||||
|
|
||||||
|
These rules cover the schema's format constraints and the validation gaps that JSON Schema cannot enforce. All of these are also caught by Tier 2 build-time validation (`make validate-content`), but catching them during authoring saves a pipeline run.
|
||||||
|
|
||||||
|
### ID and Slug Formats
|
||||||
|
|
||||||
|
| Field | Regex | Example |
|
||||||
|
|-------|-------|---------|
|
||||||
|
| `module_id` | `^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$` | `smuggling_ring_v0_1` |
|
||||||
|
| `sequence_id`, `pool_id` | `^[a-z][a-z0-9_-]*$` | `kael_exit_arc` |
|
||||||
|
| `event_id` | `^[a-z][a-z0-9_-]*$` | `kael_goes_cold` |
|
||||||
|
| `outcome_id` | `^[a-z][a-z0-9_-]*$` | `ring_exposed` |
|
||||||
|
| `sets_flag` / flag references | `^[a-z][a-z0-9_-]*$` | `kael_behavior_changed` |
|
||||||
|
| `role` (npc slot) | `^[a-z][a-z0-9-]*$` | `ring-member-exiting` |
|
||||||
|
| `named_npc` | `^npc:[a-z][a-z0-9-]*$` | `npc:kael-davan` |
|
||||||
|
| `version` | `^[0-9]+\\.[0-9]+$` | `0.1` |
|
||||||
|
|
||||||
|
Note the difference: `event_id`, `outcome_id`, `sequence_id`, and flags use underscores and hyphens (`[a-z0-9_-]*`). NPC `role` slugs use hyphens only (`[a-z0-9-]*`). Mixing them in wrong fields will fail schema validation.
|
||||||
|
|
||||||
|
### Flag Naming Convention
|
||||||
|
|
||||||
|
Flags are module-internal state. Every flag name that appears in `sets_flag` on an event **must** also appear in at least one outcome's `flags_set` or `flags_not_set` condition — or the flag serves no purpose. Convention:
|
||||||
|
|
||||||
|
- Use `snake_case` with underscores: `kael_behavior_changed`, `voss_pressure_applied`
|
||||||
|
- Name by what happened, not what it enables: `handler_pressure_applied` not `kael_ready_to_flee`
|
||||||
|
- Flags set by events accumulate — they are never automatically cleared
|
||||||
|
- A flag set by a time-triggered event (not player-triggered) cannot be used as an expiry gate (see "Common Mistakes" below)
|
||||||
|
|
||||||
|
### Axis Constraint Syntax (Generated NPC Bindings)
|
||||||
|
|
||||||
|
The `constraint` field in `axes` is a freeform string. The storyteller's NPC filter interprets it. Convention (author responsibility — schema does not enforce):
|
||||||
|
|
||||||
|
| Prefix | Example | Meaning |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `has_` | `has_major_secret` | NPC axis value includes this descriptor |
|
||||||
|
| `min_contentment_` | `min_contentment_-3` | Contentment axis value ≤ N (more discontented) |
|
||||||
|
| `not_` | `not_combat_trained` | Axis value does NOT include this descriptor |
|
||||||
|
| `is_` | `is_ring_member` | Boolean flag set on NPC profile |
|
||||||
|
|
||||||
|
### What JSON Schema Cannot Validate (Tier 2 Catches These)
|
||||||
|
|
||||||
|
| Issue | Where to Look | Impact |
|
||||||
|
|-------|--------------|--------|
|
||||||
|
| `fact_id` not defined in `global/knowledge/` | Effect `fact_becomes_discoverable`, outcome `facts_known` | Fact silently never becomes discoverable |
|
||||||
|
| `sets_flag` name not referenced in any outcome condition | Event `sets_flag` | Flag is set but never meaningful |
|
||||||
|
| `flags_set`/`flags_not_set` reference flag never set by any event | Outcome conditions | Condition permanently true or false |
|
||||||
|
| `ticks_since_event.after_event` references unknown event_id | Event trigger | Trigger never fires |
|
||||||
|
| `named_npc` ID doesn't exist in district NPC profiles | NPC requirements | Load-time failure |
|
||||||
|
| Multiple outcomes have `is_expiry: true` | Outcomes list | Undefined storyteller behavior |
|
||||||
|
| `faction` in outcome effects not in `global/factions/` | Outcome effects | Effect silently ignored |
|
||||||
|
|
||||||
|
### The Expiry Condition Pitfall
|
||||||
|
|
||||||
|
This is the most common authoring mistake for expiry outcomes. **The expiry condition must use `facts_not_known`, not `flags_not_set`.** Reason:
|
||||||
|
|
||||||
|
Events with `ticks_since_activation` triggers fire automatically without player engagement. If an auto-firing event sets a flag, and your expiry checks `flags_not_set: [that_flag]`, the expiry condition becomes permanently false after the event fires — the module can never expire quietly.
|
||||||
|
|
||||||
|
**Wrong:**
|
||||||
|
```yaml
|
||||||
|
# kael_goes_cold fires automatically at tick 300, sets kael_behavior_changed
|
||||||
|
# This expiry can never fire after tick 300
|
||||||
|
- outcome_id: module_abandoned
|
||||||
|
is_expiry: true
|
||||||
|
conditions:
|
||||||
|
flags_not_set:
|
||||||
|
- kael_behavior_changed # This flag is always set by tick 300
|
||||||
|
ticks_since_activation: 5400
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
```yaml
|
||||||
|
# facts_not_known gates on player investigative action, not auto-fired events
|
||||||
|
- outcome_id: module_abandoned
|
||||||
|
is_expiry: true
|
||||||
|
conditions:
|
||||||
|
facts_not_known:
|
||||||
|
- "ring.cargo_discrepancy_pattern" # Only known if player examined terminal
|
||||||
|
- "ring.kael_unauthorized_corridor_access" # Only known if player observed Kael
|
||||||
|
ticks_since_activation: 5400
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Before Submitting a New Module
|
||||||
|
|
||||||
|
- [ ] `module_id` uses correct format and doesn't collide with existing modules
|
||||||
|
- [ ] `dual_lens` is written and shows clearly different experiences per character
|
||||||
|
- [ ] `min_play_ticks` ≥ 1800 (30 minutes at 1 tick/second)
|
||||||
|
- [ ] Every event sequence step has at least one tick-based trigger
|
||||||
|
- [ ] Every `fact_becomes_discoverable` effect has a `discovery_method` note
|
||||||
|
- [ ] The module includes at least one named FRIEND-pattern NPC (for v0.1 modules)
|
||||||
|
- [ ] Expiry outcome is present (`is_expiry: true`) with conditions gated on `facts_not_known`, NOT `flags_not_set`
|
||||||
|
- [ ] All outcomes have been reviewed for moral ambiguity — no "obviously correct" resolution
|
||||||
|
- [ ] `npc_requirements` covers every role referenced in events and outcomes
|
||||||
|
- [ ] All fact IDs used in effects/conditions exist in `global/knowledge/`
|
||||||
|
- [ ] All `sets_flag` names appear in at least one outcome condition
|
||||||
|
- [ ] All `flags_set`/`flags_not_set` names are set by at least one event's `sets_flag`
|
||||||
|
- [ ] `make validate-content` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
| Topic | Location |
|
||||||
|
|-------|----------|
|
||||||
|
| Three-tier content model | `decisions/content.md` D-023 |
|
||||||
|
| NPC 10-axis model | `decisions/content.md` D-024 |
|
||||||
|
| Vertical slice scope | `decisions/scope.md` D-027 |
|
||||||
|
| Population ratios | `decisions/content.md` D-029 |
|
||||||
|
| THE FRIEND pattern | `decisions/content.md` D-034 |
|
||||||
|
| Smuggling ring module | `content/modules/tier1/smuggling_ring_v0_1.yaml` |
|
||||||
|
| Drama module schema | `content/schemas/drama_module.schema.yaml` |
|
||||||
|
| Fact catalog | `content/global/knowledge/` |
|
||||||
|
| NPC profiles (v0.1) | `content/campaigns/main/systems/krenn/` |
|
||||||
|
| Storyteller stub | `server/src/storyteller/mod.rs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ticket #158 — Tier 1 drama module schema. Paula (dramatic structure), Gestalt (schema format), Mellanie (authoring review).*
|
||||||
@@ -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-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-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-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) |
|
| [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": {
|
"tab-save": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 116, "width": 120, "height": 32,
|
"left": 140, "top": 116, "width": 120, "height": 32,
|
||||||
"fillColor": "#1a2030",
|
"fillColor": "#0d1018",
|
||||||
"strokeColor": "#c8d0e0",
|
"strokeColor": "#333340",
|
||||||
"corners": [2, 2, 0, 0]
|
"corners": [2, 2, 0, 0]
|
||||||
},
|
},
|
||||||
"tab-save-text": {
|
"tab-save-text": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 156, "top": 125,
|
"left": 156, "top": 125,
|
||||||
"text": "SAVE",
|
"text": "SAVE",
|
||||||
"fontColor": "#c8d0e0",
|
"fontColor": "#556677",
|
||||||
"fontSize": 13
|
"fontSize": 13
|
||||||
},
|
},
|
||||||
"tab-load": {
|
"tab-load": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 264, "top": 116, "width": 120, "height": 32,
|
"left": 264, "top": 116, "width": 120, "height": 32,
|
||||||
"fillColor": "#0d1018",
|
"fillColor": "#1a2030",
|
||||||
"strokeColor": "#333340",
|
"strokeColor": "#c8d0e0",
|
||||||
"corners": [2, 2, 0, 0]
|
"corners": [2, 2, 0, 0]
|
||||||
},
|
},
|
||||||
"tab-load-text": {
|
"tab-load-text": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 280, "top": 125,
|
"left": 280, "top": 125,
|
||||||
"text": "LOAD",
|
"text": "LOAD",
|
||||||
"fontColor": "#556677",
|
"fontColor": "#c8d0e0",
|
||||||
"fontSize": 13
|
"fontSize": 13
|
||||||
},
|
},
|
||||||
"save-slot-1-active": {
|
|
||||||
|
"game-1-header": {
|
||||||
"type": "Rectangle",
|
"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",
|
"fillColor": "#1a2030",
|
||||||
"strokeColor": "#c8d8f0",
|
"strokeColor": "#c8d8f0",
|
||||||
"corners": [2, 2, 2, 2]
|
"corners": [2, 2, 2, 2]
|
||||||
},
|
},
|
||||||
"save-slot-1-accent": {
|
"qs-accent": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 156, "width": 3, "height": 72,
|
"left": 158, "top": 192, "width": 3, "height": 62,
|
||||||
"fillColor": "#c8d8f0",
|
"fillColor": "#c8d8f0",
|
||||||
"strokeColor": "#c8d8f0"
|
"strokeColor": "#c8d8f0"
|
||||||
},
|
},
|
||||||
"slot-1-date": {
|
"qs-label": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 162,
|
"left": 170, "top": 200,
|
||||||
"text": "AUTOSAVE // Day 1, 07:42",
|
"text": "QUICKSAVE // Day 3, 14:22",
|
||||||
"fontColor": "#c8d0e0",
|
"fontColor": "#c8d0e0",
|
||||||
"fontSize": 13
|
"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": {
|
"slot-1-location": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 180,
|
"left": 170, "top": 354,
|
||||||
"text": "The Terminal — morning shift // Detective",
|
"text": "Hab quarters \u2014 evening",
|
||||||
"fontColor": "#8899aa",
|
"fontColor": "#556677",
|
||||||
"fontSize": 12
|
"fontSize": 12
|
||||||
},
|
},
|
||||||
"slot-1-timestamp": {
|
"slot-1-timestamp": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 198,
|
"left": 170, "top": 370,
|
||||||
"text": "saved: 2026-02-23 14:31",
|
"text": "saved: 2026-02-25 14:48",
|
||||||
"fontColor": "#556677",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"slot-1-actions": {
|
|
||||||
"type": "Text",
|
"game-2-header": {
|
||||||
"left": 860, "top": 175,
|
|
||||||
"text": "[Enter] Overwrite / Load",
|
|
||||||
"fontColor": "#c8d8f0",
|
|
||||||
"fontSize": 12
|
|
||||||
},
|
|
||||||
"save-slot-2": {
|
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 236, "width": 860, "height": 72,
|
"left": 140, "top": 404, "width": 860, "height": 32,
|
||||||
"fillColor": "#0e1118",
|
"fillColor": "#111520",
|
||||||
"strokeColor": "#333340",
|
"strokeColor": "#333340",
|
||||||
"corners": [2, 2, 2, 2]
|
"corners": [2, 2, 2, 2]
|
||||||
},
|
},
|
||||||
"slot-2-date": {
|
"game-2-title": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 250,
|
"left": 152, "top": 413,
|
||||||
"text": "SLOT 2 // Day 1, 06:15",
|
"text": "\u25b6 SMUGGLER \u2014 Day 1 // The Terminal // last played: yesterday",
|
||||||
"fontColor": "#8899aa",
|
"fontColor": "#8899aa",
|
||||||
"fontSize": 13
|
|
||||||
},
|
|
||||||
"slot-2-location": {
|
|
||||||
"type": "Text",
|
|
||||||
"left": 152, "top": 268,
|
|
||||||
"text": "Arrival — entering Sova Transit // Detective",
|
|
||||||
"fontColor": "#556677",
|
|
||||||
"fontSize": 12
|
"fontSize": 12
|
||||||
},
|
},
|
||||||
"slot-2-timestamp": {
|
"game-2-count": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 286,
|
"left": 920, "top": 413,
|
||||||
"text": "saved: 2026-02-23 13:10",
|
"text": "2 saves",
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"save-slot-3": {
|
|
||||||
|
"game-3-header": {
|
||||||
"type": "Rectangle",
|
"type": "Rectangle",
|
||||||
"left": 140, "top": 316, "width": 860, "height": 72,
|
"left": 140, "top": 444, "width": 860, "height": 32,
|
||||||
"fillColor": "#0e1118",
|
"fillColor": "#111520",
|
||||||
"strokeColor": "#333340",
|
"strokeColor": "#333340",
|
||||||
"corners": [2, 2, 2, 2]
|
"corners": [2, 2, 2, 2]
|
||||||
},
|
},
|
||||||
"slot-3-date": {
|
"game-3-title": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 330,
|
"left": 152, "top": 453,
|
||||||
"text": "SLOT 3 // Day 1, 07:30",
|
"text": "\u25b6 DETECTIVE \u2014 Day 7 // Sova Transit // last played: Feb 20",
|
||||||
"fontColor": "#8899aa",
|
"fontColor": "#8899aa",
|
||||||
"fontSize": 13
|
|
||||||
},
|
|
||||||
"slot-3-location": {
|
|
||||||
"type": "Text",
|
|
||||||
"left": 152, "top": 348,
|
|
||||||
"text": "Corridor B-7 — Kael spotted // Smuggler",
|
|
||||||
"fontColor": "#556677",
|
|
||||||
"fontSize": 12
|
"fontSize": 12
|
||||||
},
|
},
|
||||||
"slot-3-timestamp": {
|
"game-3-count": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 152, "top": 366,
|
"left": 920, "top": 453,
|
||||||
"text": "saved: 2026-02-23 12:48",
|
"text": "5 saves",
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"empty-slots-label": {
|
|
||||||
|
"footer-note": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 140, "top": 400,
|
"left": 140, "top": 660, "width": 860,
|
||||||
"text": "SLOTS 4-8 — empty",
|
"text": "Autosave: zone transitions, conversation ends, significant events // F5 quicksave // F6 quickload",
|
||||||
"fontColor": "#2a3040",
|
|
||||||
"fontSize": 12
|
|
||||||
},
|
|
||||||
"save-note": {
|
|
||||||
"type": "Text",
|
|
||||||
"left": 140, "top": 660,
|
|
||||||
"text": "Autosave on: zone transitions, conversation ends, significant events",
|
|
||||||
"fontColor": "#3a4455",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 11
|
"fontSize": 11,
|
||||||
|
"wordWrap": true
|
||||||
},
|
},
|
||||||
"annotation-title": {
|
"annotation-title": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 16, "top": 720,
|
"left": 16, "top": 720,
|
||||||
"text": "v0.1 SAVE / LOAD SCREEN",
|
"text": "v0.1 SAVE / LOAD \u2014 LOAD TAB (D-085)",
|
||||||
"fontColor": "#556677",
|
"fontColor": "#556677",
|
||||||
"fontSize": 11
|
"fontSize": 11
|
||||||
},
|
},
|
||||||
"annotation-notes": {
|
"annotation-notes": {
|
||||||
"type": "Text",
|
"type": "Text",
|
||||||
"left": 16, "top": 734, "width": 1100,
|
"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",
|
"fontColor": "#3a4455",
|
||||||
"fontSize": 10,
|
"fontSize": 10,
|
||||||
"wordWrap": true
|
"wordWrap": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,235 @@
|
|||||||
|
# District Topology — Sova Transit District
|
||||||
|
# D-093 spatial layout. D-094 hierarchy: 256×256 vt (4×4 blocks).
|
||||||
|
# North = span gate entry. South-east = tram entry.
|
||||||
|
|
||||||
|
direction: down
|
||||||
|
|
||||||
|
vars: {
|
||||||
|
bg: "#1a1e24"
|
||||||
|
txt: "#c8d0e0"
|
||||||
|
acc: "#c8d8f0"
|
||||||
|
|
||||||
|
pub: "#1a3320"
|
||||||
|
spub: "#2e2a10"
|
||||||
|
spriv: "#2e1a08"
|
||||||
|
priv: "#2a0e0e"
|
||||||
|
comm: "#0e1a2e"
|
||||||
|
neut: "#1e2228"
|
||||||
|
|
||||||
|
s-pub: "#3a8a50"
|
||||||
|
s-spub: "#b8a020"
|
||||||
|
s-spriv: "#c86010"
|
||||||
|
s-priv: "#c02020"
|
||||||
|
s-comm: "#3060c0"
|
||||||
|
s-neut: "#4a5060"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── LEGEND ──
|
||||||
|
|
||||||
|
legend: Legend {
|
||||||
|
style.fill: ${bg}
|
||||||
|
style.stroke: ${acc}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.font-size: 11
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
l1: Public { style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt} }
|
||||||
|
l2: Semi-pub { style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt} }
|
||||||
|
l3: Semi-priv { style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt} }
|
||||||
|
l4: Private { style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt} }
|
||||||
|
l5: Commission { style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt} }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── NORTH ENTRY: SPAN GATE ──
|
||||||
|
|
||||||
|
span_gate: The Ring\n(span gate aperture) {
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: ${comm}
|
||||||
|
style.stroke: ${s-comm}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── GATE CLUSTER ──
|
||||||
|
|
||||||
|
gate: Gate Cluster · 40×32 vt {
|
||||||
|
style.fill: ${bg}
|
||||||
|
style.stroke: ${s-comm}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
|
||||||
|
aperture: Aperture\n8×4 {
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
staging: Freight Staging\n24×8 {
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
pab: Passenger Arrival\n12×8 {
|
||||||
|
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
customs: Customs Lanes\n(freight 5×4vt + ped 3×2vt) {
|
||||||
|
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
concourse: Concourse\n40×8 · PUBLIC {
|
||||||
|
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
gallery: Gallery · z=2\n32×10 · COMMISSION {
|
||||||
|
style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
|
||||||
|
aperture -> staging: freight { style.stroke: ${s-priv} }
|
||||||
|
aperture -> pab: passenger { style.stroke: ${s-spub} }
|
||||||
|
staging -> customs { style.stroke: ${s-spriv} }
|
||||||
|
pab -> customs { style.stroke: ${s-spub} }
|
||||||
|
customs -> concourse { style.stroke: ${s-pub} }
|
||||||
|
concourse -> gallery: "staircase (Commission)" {
|
||||||
|
style.stroke: ${s-comm}; style.stroke-dash: 4
|
||||||
|
}
|
||||||
|
gallery -> customs: "LOS z=2 down" {
|
||||||
|
style.stroke: ${s-comm}; style.stroke-dash: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span_gate -> gate.aperture: "dual-use transit\n(90s flicker)" {
|
||||||
|
style.stroke: ${s-comm}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── TERMINAL ──
|
||||||
|
|
||||||
|
terminal: Terminal · 44×28 vt {
|
||||||
|
style.fill: ${bg}
|
||||||
|
style.stroke: ${s-spriv}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
|
||||||
|
forecourt: Forecourt\n44×4 {
|
||||||
|
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
cargo: Cargo Floor + Main Corridor {
|
||||||
|
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
storage: Restricted Storage\n(single coded door) {
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
hatch_t: M-HATCH-T {
|
||||||
|
shape: diamond
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
|
||||||
|
forecourt -> cargo { style.stroke: ${s-spriv} }
|
||||||
|
cargo -> storage: "coded door" { style.stroke: ${s-priv} }
|
||||||
|
storage -> hatch_t { style.stroke: ${s-priv} }
|
||||||
|
}
|
||||||
|
|
||||||
|
gate.concourse -> terminal.forecourt: "district spine (south)" {
|
||||||
|
style.stroke: ${s-pub}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── TRANSITION CORRIDOR ──
|
||||||
|
|
||||||
|
corridor: Transition Corridor\n~40×6 vt {
|
||||||
|
style.fill: ${spub}
|
||||||
|
style.stroke: ${s-spub}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.cargo -> corridor: "public route" {
|
||||||
|
style.stroke: ${s-spub}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── BAR ──
|
||||||
|
|
||||||
|
bar: The Last Shift · 28×22 vt {
|
||||||
|
style.fill: ${bg}
|
||||||
|
style.stroke: ${s-pub}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
|
||||||
|
approach: Bar Approach\n28×3 {
|
||||||
|
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
floor: Main Floor\n(corner booth · card table) {
|
||||||
|
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
bathroom: Bathroom Corridor\n(east ext.) {
|
||||||
|
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
backroom: Back Room\n(Lera) {
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
hatch_b: M-HATCH-B {
|
||||||
|
shape: diamond
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
|
||||||
|
approach -> floor { style.stroke: ${s-pub} }
|
||||||
|
floor -> bathroom: "east door" { style.stroke: ${s-spriv} }
|
||||||
|
floor -> backroom: "staff only" { style.stroke: ${s-priv} }
|
||||||
|
bathroom -> hatch_b { style.stroke: ${s-priv} }
|
||||||
|
}
|
||||||
|
|
||||||
|
corridor -> bar.approach: "bar-side decompression" {
|
||||||
|
style.stroke: ${s-spub}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── MAINTENANCE CORRIDOR (z=0) ──
|
||||||
|
|
||||||
|
maint: Maintenance Corridor\n(z=0 · Era 1 · 2vt wide)\nzero public traffic {
|
||||||
|
style.fill: ${priv}
|
||||||
|
style.stroke: ${s-priv}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
style.stroke-dash: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
junc: JUNC-1 {
|
||||||
|
shape: diamond
|
||||||
|
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal.hatch_t -> maint: "z=1 down z=0" {
|
||||||
|
style.stroke: ${s-priv}; style.stroke-dash: 5
|
||||||
|
}
|
||||||
|
maint -> junc { style.stroke: ${s-priv}; style.stroke-dash: 5 }
|
||||||
|
junc -> bar.hatch_b: "z=0 up z=1" {
|
||||||
|
style.stroke: ${s-priv}; style.stroke-dash: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── SOUTH-EAST ENTRY: TRAM ──
|
||||||
|
|
||||||
|
the_loop: The Loop\n(station tram · 6 districts) {
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: ${neut}
|
||||||
|
style.stroke: ${s-neut}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
}
|
||||||
|
|
||||||
|
platform: Transit Platform\n~12×8 vt · bar-side\n(encounter node) {
|
||||||
|
style.fill: ${pub}
|
||||||
|
style.stroke: ${s-pub}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
the_loop -> platform: "workers arrive here (G-11)" {
|
||||||
|
style.stroke: ${s-neut}
|
||||||
|
}
|
||||||
|
platform -> bar.approach: "adjacent" {
|
||||||
|
style.stroke: ${s-pub}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── SECTOR 3 ──
|
||||||
|
|
||||||
|
sector3: Sector 3 Residential\n~15×12 vt · Drin/Naia {
|
||||||
|
style.fill: ${spub}
|
||||||
|
style.stroke: ${s-spub}
|
||||||
|
style.font-color: ${txt}
|
||||||
|
style.border-radius: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
sector3 -> terminal.forecourt: "near Terminal" {
|
||||||
|
style.stroke: ${s-spub}; style.stroke-dash: 3
|
||||||
|
}
|
||||||
|
sector3 -> maint: "adjacent to spine" {
|
||||||
|
style.stroke: ${s-neut}; style.stroke-dash: 3
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1006 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user