diff --git a/.claude/agents/qatux.md b/.claude/agents/qatux.md index e6e2082e4..ab83010ea 100644 --- a/.claude/agents/qatux.md +++ b/.claude/agents/qatux.md @@ -44,7 +44,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli - **Work in dedicated round files:** All new rounds happen in `docs/discussions/round-NN-topic.md` from the start. DISCUSSION.md is retired for new content. - **Update the discussion index ONLY when closing:** After a round is formally closed, update `docs/discussions/README.md` with the round entry (number, topic, decisions produced, file link). - **Update briefings:** After a round produces new decisions, update the relevant agent briefing files in `docs/briefings/`. -- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `db/connectors/qdrant-index `. +- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `tooling/db/qdrant-index `. ## Team workflow (mandatory) diff --git a/.claude/rules/local-services.md b/.claude/rules/local-services.md index 4decd8975..df1a95c22 100644 --- a/.claude/rules/local-services.md +++ b/.claude/rules/local-services.md @@ -1,6 +1,6 @@ # Local Services -Endpoints are also preconfigured in `db/connectors/config.json`. +Endpoints are also preconfigured in `tooling/db/config.json`. - **Gitea:** `http://git.schweitz.internal` (login: `schweitz`) - **Qdrant:** `http://tower-of-joy:6333/` diff --git a/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index 02a3fcbb4..63523ee71 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -17,11 +17,15 @@ docs/ workshops/ # Workshop briefs and outputs db/ schema.sql # Database schema - connectors/ # Connector scripts for SQLite and Qdrant + connectors/ # Symlink → tooling/db/ (backwards compat, remove after Sprint 22) +tooling/ + db/ # Connector scripts for SQLite, Qdrant, and audio config.json # Endpoint configuration ticket # Ticket CLI + sprint # Sprint lifecycle CLI sqlite_connector.py # SQLite mini MCP qdrant_connector.py # Qdrant + ollama mini MCP + audio_connector.py # Stable Audio Open connector .claude/ agents/ # Agent personality files skills/ # Skill definitions diff --git a/.claude/settings.json b/.claude/settings.json index 16721c97e..7163418b8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,6 +25,23 @@ "Bash(git ls-tree *)", "Bash(git rev-parse --show-toplevel)", + "Bash(tooling/db/ticket *)", + "Bash(tooling/db/sprint *)", + "Bash(tooling/db/sqlite-query *)", + "Bash(tooling/db/sqlite-exec *)", + "Bash(tooling/db/qdrant-search *)", + "Bash(tooling/db/qdrant-index *)", + "Bash(tooling/db/qdrant-health)", + "Bash(tooling/db/qdrant-count)", + "Bash(tooling/db/sqlite-init)", + "Bash(tooling/db/decisions-sync)", + "Bash(tooling/db/decision *)", + + "Bash(tooling/db/audio-generate *)", + "Bash(tooling/db/audio-health)", + "Bash(tooling/db/audio-post *)", + "Bash(tooling/db/audio-batch *)", + "Bash(db/connectors/ticket *)", "Bash(db/connectors/sprint *)", "Bash(db/connectors/sqlite-query *)", @@ -35,10 +52,12 @@ "Bash(db/connectors/qdrant-count)", "Bash(db/connectors/sqlite-init)", "Bash(db/connectors/decisions-sync)", + "Bash(db/connectors/decision *)", "Bash(db/connectors/audio-generate *)", "Bash(db/connectors/audio-health)", "Bash(db/connectors/audio-post *)", + "Bash(db/connectors/audio-batch *)", "Bash(make *)", "Bash(make)", @@ -46,6 +65,14 @@ "Bash(tea *)", "Bash(tooling/tea-comment *)", + "Bash(cargo test *)", + "Bash(cargo test)", + "Bash(cargo build *)", + "Bash(cargo build)", + "Bash(cargo check *)", + "Bash(cargo check)", + "Bash(tests/run-*)", + "Bash(chmod *)", "Bash(ls *)", "Bash(find *)", diff --git a/.claude/skills/audio-gen/SKILL.md b/.claude/skills/audio-gen/SKILL.md index ef6a17526..4c820e289 100644 --- a/.claude/skills/audio-gen/SKILL.md +++ b/.claude/skills/audio-gen/SKILL.md @@ -13,7 +13,7 @@ description: > # Audio Generation — The Settled Reach Generate sonically consistent audio assets using the Stable Audio Open API via -wrapper scripts at `db/connectors/audio-*`. +wrapper scripts at `tooling/db/audio-*`. Asset descriptions, filenames, bus routing, and design intent are documented in `docs/assets/audio/`. This skill provides the prompt system, generation @@ -25,21 +25,21 @@ workflow, and quality validation. ```bash # Check API health -db/connectors/audio-health +tooling/db/audio-health # Generate a single asset (WAV only) -db/connectors/audio-generate "prompt text" \ +tooling/db/audio-generate "prompt text" \ --duration 10 --steps 100 --cfg 7 \ --output path/to/output.wav # Generate + post-process in one command (WAV → trim → normalize → OGG) -db/connectors/audio-generate "prompt text" \ +tooling/db/audio-generate "prompt text" \ --duration 10 --steps 100 --cfg 7 \ --output path/to/gen/intermediate.wav \ --output-ogg client/assets/audio/final.ogg # Batch-generate from a manifest (preferred for multiple assets) -db/connectors/audio-batch docs/assets/audio/batch-s10-327.json +tooling/db/audio-batch docs/assets/audio/batch-s10-327.json ``` ### Parameters @@ -138,16 +138,16 @@ AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory. ```bash # Full run -db/connectors/audio-batch docs/assets/audio/batch-s10-327.json +tooling/db/audio-batch docs/assets/audio/batch-s10-327.json # Dry run — preview what would be generated -db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run +tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --dry-run # Generate only specific assets -db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002 +tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002 # Skip assets that already have OGG files -db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing +tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing ``` ### 3. Update asset docs with prompts @@ -190,8 +190,8 @@ For one-off generation or iteration on a specific asset: 2. Read `references/sonic-palette.md` for the sonic family prefix. 3. Read `references/category-templates.md` for the matching template. 4. Assemble the full prompt. -5. Run `db/connectors/audio-health` to verify the API is up. -6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to +5. Run `tooling/db/audio-health` to verify the API is up. +6. Run `tooling/db/audio-generate` with `--post` or `--output-ogg` to generate and post-process in one step. 7. Verify the output (file size, duration). 8. Update the asset status and prompt in `docs/assets/audio/{category}.md`. @@ -218,12 +218,12 @@ If you need to post-process separately (e.g., re-normalizing an existing file): ```bash # Full pipeline: trim → normalize → convert -db/connectors/audio-post pipeline input.wav --output output.ogg +tooling/db/audio-post pipeline input.wav --output output.ogg # Individual steps -db/connectors/audio-post trim input.wav -db/connectors/audio-post normalize input.wav --lufs -16 -db/connectors/audio-post convert input.wav --output output.ogg +tooling/db/audio-post trim input.wav +tooling/db/audio-post normalize input.wav --lufs -16 +tooling/db/audio-post convert input.wav --output output.ogg ``` ## Manual Synthesis (Insert-Tech Sounds) diff --git a/.claude/skills/bug-report/SKILL.md b/.claude/skills/bug-report/SKILL.md index b23bd634a..4d06aca63 100644 --- a/.claude/skills/bug-report/SKILL.md +++ b/.claude/skills/bug-report/SKILL.md @@ -169,7 +169,7 @@ Construct the ticket title and description from the report summary and any investigation findings. Use the ticket CLI: ```bash -db/connectors/ticket create bug "{title}" --team {team} --description "{description}" +tooling/db/ticket create bug "{title}" --team {team} --description "{description}" ``` The description should include: diff --git a/.claude/skills/docs-search/SKILL.md b/.claude/skills/docs-search/SKILL.md index f36947254..fb20cc01e 100644 --- a/.claude/skills/docs-search/SKILL.md +++ b/.claude/skills/docs-search/SKILL.md @@ -20,14 +20,14 @@ and workflows. For precise indexing of specific content: ```bash -python3 db/connectors/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading" +python3 tooling/db/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading" ``` ### Create collection Initialize the Qdrant collection (run once during setup): ```bash -python3 db/connectors/qdrant_connector.py create-collection +python3 tooling/db/qdrant_connector.py create-collection ``` ## Bulk Indexing @@ -35,7 +35,7 @@ python3 db/connectors/qdrant_connector.py create-collection Index all project documents at once: ```bash for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do - db/connectors/qdrant-index "$f" + tooling/db/qdrant-index "$f" done ``` diff --git a/.claude/skills/pr-push/SKILL.md b/.claude/skills/pr-push/SKILL.md index 82a996b23..3d8e6a5e9 100644 --- a/.claude/skills/pr-push/SKILL.md +++ b/.claude/skills/pr-push/SKILL.md @@ -123,7 +123,7 @@ Extract ticket IDs from `#NNN` patterns. For each ticket that is currently `in_progress`, update it to `review`: ```bash -db/connectors/ticket status review +tooling/db/ticket status review ``` Report which tickets were moved to review. Skip tickets that are diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 8a9241b63..28276aeb0 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -16,15 +16,17 @@ on the branch type. All reviewers must approve for a clean review. ## Workflow -### 0. Branch guard — MUST be on `main` +### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree ```bash git branch --show-current ``` If the current branch is **not `main`**, stop immediately and tell the user: -"PR reviews must be run from the `main` worktree. Switch to `main` first." -Do NOT proceed with the review from a team branch. +"PR reviews must be run by a Claude instance in the `main` worktree." +Do NOT proceed with the review. Do NOT work around this by reading files +from another worktree — the review agent itself must be running in main. +Stop and wait for the user to invoke `/pr-review` from main. ### 1. Determine the branch to review @@ -81,12 +83,12 @@ raw diff to reviewers — cleaner context, better reviews. worktrees. Each team branch is checked out at: ``` -/var/home/jeroenschweitzer/Projects/settled-reach// +/var/mnt/data/projects/settled-reach// ``` For example, the `copy` branch lives at: ``` -/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/... +/var/mnt/data/projects/settled-reach/copy/content/dialogue/... ``` **All reviewer agents** (regardless of Bash access) should read source files @@ -101,10 +103,10 @@ worktree path. Example instruction for agents: ``` Read the changed files from the branch worktree. The branch is checked -out at: /var/home/jeroenschweitzer/Projects/settled-reach// +out at: /var/mnt/data/projects/settled-reach// For example, to read `content/dialogue/the-terminal/kael-davan.yaml`, -use: /var/home/jeroenschweitzer/Projects/settled-reach//content/dialogue/the-terminal/kael-davan.yaml +use: /var/mnt/data/projects/settled-reach//content/dialogue/the-terminal/kael-davan.yaml ``` Also tell agents to read relevant `decisions/*.md` files from the same diff --git a/.claude/skills/pr-review/references/reviewer-profiles.md b/.claude/skills/pr-review/references/reviewer-profiles.md index 701bbafba..8f9773fce 100644 --- a/.claude/skills/pr-review/references/reviewer-profiles.md +++ b/.claude/skills/pr-review/references/reviewer-profiles.md @@ -3,7 +3,7 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost. **All agents read from worktree paths.** Each branch is checked out at: -`/var/home/jeroenschweitzer/Projects/settled-reach//` +`/var/mnt/data/projects/settled-reach//` Tell every reviewer agent to read source files from the worktree using the Read tool. Include the worktree base path and a list of changed files in diff --git a/.claude/skills/sprint-plan/SKILL.md b/.claude/skills/sprint-plan/SKILL.md index ec0618a48..4e8b22a6c 100644 --- a/.claude/skills/sprint-plan/SKILL.md +++ b/.claude/skills/sprint-plan/SKILL.md @@ -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 | | `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides | | `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 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 ### 1. Run sprint prepare @@ -58,7 +88,7 @@ file so the team knows who to spawn. Get carry-overs, backlog candidates, and decision gaps in one shot: ```bash -db/connectors/sprint prepare +tooling/db/sprint prepare ``` This auto-detects the next sprint number (max ID + 1), creates the sprint @@ -73,10 +103,10 @@ record in `planning` status if needed, and outputs: For critical epics, check their children for granular candidates: ```bash -db/connectors/ticket children +tooling/db/ticket children ``` -Use `db/connectors/ticket show --brief [...]` to quickly scan multiple tickets. +Use `tooling/db/ticket show --brief [...]` to quickly scan multiple tickets. ### 3. Read existing code state @@ -154,14 +184,14 @@ Update it with the theme and goal, then assign tickets: ```bash # Update the sprint with theme and goal -db/connectors/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N" +tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N" # Assign tickets -db/connectors/ticket sprint assign +tooling/db/ticket sprint assign ``` The sprint stays in `planning` status until explicitly activated via -`db/connectors/sprint start`. This prevents starting an unplanned sprint. +`tooling/db/sprint start`. This prevents starting an unplanned sprint. ### 8. Present summary diff --git a/.claude/skills/sprint-plan/references/briefing-template.md b/.claude/skills/sprint-plan/references/briefing-template.md index 5a81729cf..34adf6320 100644 --- a/.claude/skills/sprint-plan/references/briefing-template.md +++ b/.claude/skills/sprint-plan/references/briefing-template.md @@ -26,7 +26,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/.md`. |---|-------|------------| | #ID | Title | #dependency or — | -Use `db/connectors/ticket show ` for full details. +Use `tooling/db/ticket show ` for full details. ## Key Decisions diff --git a/.claude/skills/sprint-start/SKILL.md b/.claude/skills/sprint-start/SKILL.md index 541ccd0ec..6a800083d 100644 --- a/.claude/skills/sprint-start/SKILL.md +++ b/.claude/skills/sprint-start/SKILL.md @@ -38,7 +38,7 @@ When `/sprint-start` is run on `main`, assess the current sprint state and do the next right thing. Query the database to determine the state: ```bash -db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3" +tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3" ``` Then follow the **first matching case**: @@ -48,7 +48,7 @@ Then follow the **first matching case**: First, check whether the sprint's work is actually done: ```bash -db/connectors/sprint status +tooling/db/sprint status ``` This shows ticket counts by status (done, in_progress, backlog). @@ -81,7 +81,7 @@ explicitly chooses to close. #### A1. Close the active sprint ```bash -db/connectors/sprint stop +tooling/db/sprint stop ``` This marks the active sprint as completed and lists carry-over candidates. @@ -142,7 +142,7 @@ A sprint is ready to activate. Verify it looks complete: ``` 2. Check the ticket count: ```bash - db/connectors/sprint status --sprint N + tooling/db/sprint status --sprint N ``` If briefings are missing or the sprint has 0 tickets, report the gap @@ -151,7 +151,7 @@ and suggest running `/sprint-plan` to complete planning. If everything looks ready, activate the sprint: ```bash -db/connectors/sprint start +tooling/db/sprint start ``` Then report: @@ -184,7 +184,7 @@ If the merge has conflicts, report them and stop — do not force-resolve. Run the sprint CLI to get the full context dump in one shot: ```bash -db/connectors/sprint start-work +tooling/db/sprint start-work ``` This auto-detects the active sprint and current team from the branch. @@ -204,7 +204,7 @@ If no matching briefing exists for the team, suggest running For tickets that need more detail than the `start-work` summary provides: ```bash -db/connectors/ticket show +tooling/db/ticket show ``` ### 6. Read key decisions @@ -218,7 +218,7 @@ Mark all actionable (unblocked, non-done) tickets in the sprint as `in_progress`: ```bash -db/connectors/ticket status in_progress +tooling/db/ticket status in_progress ``` Then output a summary: @@ -288,26 +288,65 @@ Task( prompt: "You are on the {team} team for Sprint {N}. Branch: `{team}` - RULES: - - GIT: Do NOT run any git commands (commit, push, pull, merge, - checkout, branch, stash, tag, etc.). All git operations are - handled by the team lead. - - DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use - the exact command with no wrappers or chaining. Examples: - db/connectors/ticket show 528 - db/connectors/ticket list --sprint {N} - Do NOT prepend python3, do NOT chain with && or ;, do NOT - add cleanup commands. Just the bare command. + RULES (NON-NEGOTIABLE): + + 1. GIT: Do NOT run any git commands (commit, push, pull, merge, + checkout, branch, stash, tag, etc.). All git operations are + handled by the team lead. No exceptions. + + 2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use + the exact command with no wrappers or chaining. Examples: + tooling/db/ticket show 528 + tooling/db/ticket list --sprint {N} + Do NOT prepend python3, do NOT chain with && or ;, do NOT + add cleanup commands. Just the bare command. + + 3. READ BEFORE WRITE: Before modifying ANY file, Read it first. + Before creating a new file, Glob for similar files to learn + the existing patterns (naming, structure, imports). Follow + the conventions you find — do not invent new ones. + + 4. VERIFY AFTER WRITE: After implementing a change, grep for + all references to functions/properties/classes you modified + or removed. If you renamed, moved, or deleted something, + update EVERY call site. Missing a call site breaks tests + and blocks the team. + + 5. NO PARTIAL WORK: Do not mark a task completed unless ALL + parts of the ticket are implemented. If the ticket says + 'deliver A, B, and C', all three must exist and work. If + you cannot complete part of a task, message the team lead + explaining what is blocked and what remains — do NOT mark + it completed. + + 6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve + in 3 attempts, stop and message the team lead immediately. + Do not silently skip work or leave stubs. Do not move to + the next task while the current one is incomplete. + + 7. BACKWARD COMPATIBILITY: When extracting, moving, or + refactoring code, ensure all existing consumers still work. + Add proxy methods/properties if needed. Grep for the old + name to find every call site. + + WORKFLOW: 1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md 2. Read the decision files referenced in the briefing. 3. Check TaskList for available work. 4. Claim an unblocked task (TaskUpdate with owner: your name), mark it in_progress, and implement it. - 5. When done, mark the task completed and check TaskList for - the next available task. + 5. Before marking done, verify: + - All deliverables from the ticket exist (not just some) + - No broken references (grep for changed names/signatures) + - New files follow existing naming and directory conventions + - Modified files still parse (no syntax errors) + 6. Mark the task completed and check TaskList for the next + available task. + 7. If no tasks remain, message the team lead. Do NOT shut down + on your own. - Use `db/connectors/ticket show ` for full ticket specs.", + Use `tooling/db/ticket show ` for full ticket specs.", description: "Sprint {N} {team}: {name}", run_in_background: true ) diff --git a/.claude/skills/sprint-status/SKILL.md b/.claude/skills/sprint-status/SKILL.md index 5c1ac9b80..ada1ccd56 100644 --- a/.claude/skills/sprint-status/SKILL.md +++ b/.claude/skills/sprint-status/SKILL.md @@ -41,7 +41,7 @@ the workflow. Run these two commands in parallel: ```bash -db/connectors/sprint sweep +tooling/db/sprint sweep ``` ```bash diff --git a/.claude/skills/ticket/SKILL.md b/.claude/skills/ticket/SKILL.md index ce4eaa732..247191da6 100644 --- a/.claude/skills/ticket/SKILL.md +++ b/.claude/skills/ticket/SKILL.md @@ -17,60 +17,60 @@ section. This skill covers the full command reference. ### List tickets (full flags) ```bash -db/connectors/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T] +tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T] ``` ### Create ticket ```bash -db/connectors/ticket create [--parent N] [--priority P] [--decision D] [--team T] +tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] ``` Types: `initiative`, `epic`, `story`, `task`, `bug` Priorities: `critical`, `high`, `medium`, `low` ### Update status ```bash -db/connectors/ticket status <id> <new_status> -db/connectors/ticket done <id> [<id> ...] +tooling/db/ticket status <id> <new_status> +tooling/db/ticket done <id> [<id> ...] ``` Statuses: `backlog`, `ready`, `in_progress`, `review`, `done`, `cancelled` ### Assignment ```bash -db/connectors/ticket assign <id> <agent> -db/connectors/ticket unassign <id> +tooling/db/ticket assign <id> <agent> +tooling/db/ticket unassign <id> ``` ### Team assignment ```bash -db/connectors/ticket team <id> <teams> +tooling/db/ticket team <id> <teams> ``` Teams are comma-separated, e.g. `server`, `client`, `server,client`. ### Sprint management ```bash -db/connectors/ticket sprint [--active] -db/connectors/ticket sprint assign <id> <sprint_id> +tooling/db/ticket sprint [--active] +tooling/db/ticket sprint assign <id> <sprint_id> ``` For sprint-scoped operations (status overview, context dumps, lifecycle), -use the dedicated sprint CLI instead: `db/connectors/sprint --help` +use the dedicated sprint CLI instead: `tooling/db/sprint --help` ### Dependencies ```bash -db/connectors/ticket deps <id> +tooling/db/ticket deps <id> ``` ### Search and browse ```bash -db/connectors/ticket search <keyword> -db/connectors/ticket epics [--status S] -db/connectors/ticket children <id> -db/connectors/ticket count [--status S] +tooling/db/ticket search <keyword> +tooling/db/ticket epics [--status S] +tooling/db/ticket children <id> +tooling/db/ticket count [--status S] ``` ### Batch show ```bash -db/connectors/ticket show --brief <id> [<id>...] +tooling/db/ticket show --brief <id> [<id>...] ``` ## Workflow diff --git a/.config/hooks/pre-commit b/.config/hooks/pre-commit index bb5bc7e6c..5a3334de7 100755 --- a/.config/hooks/pre-commit +++ b/.config/hooks/pre-commit @@ -21,6 +21,7 @@ run_check() { # --- Checks --- run_check "tooling/check-fact-ids" "fact_id validation" +run_check "tooling/check-decision-ids" "decision ID duplication" if [ "$ERRORS" -gt 0 ]; then echo "" diff --git a/.gitignore b/.gitignore index 0270ea1b6..ce6b5e78d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ Thumbs.db # Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked .claude/plans/ .claude/projects/ +.claude/agent-memory/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 16871913f..ab16a91c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,97 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Changed +- Moved connector scripts from db/connectors/ to tooling/db/ (#274) — symlink at old path for backwards compatibility + +## [v0.1.20] — 2026-02-25 + ### 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) - KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545) @@ -21,7 +111,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232) - Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern - `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies -- D-084: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling +- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling - Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states - Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552) - Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330) @@ -36,6 +126,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). - Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers - Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540) +### Fixed +- Client protocol version bumped to 15 to match server (was still at 14 after server PR #68 added save_result field) +- gen_fixtures.rs version comments changed from hardcoded 14 to PROTOCOL_VERSION constant +- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds) + ### Changed - CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/` - `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window diff --git a/CLAUDE.md b/CLAUDE.md index ae65dd053..708a170b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ server/ # Rust/bevy_ecs simulation server tooling/ # Build tools, scripts, asset pipelines tests/ # Integration and end-to-end tests docs/ # Architecture, design, briefings, sprints, workshops -db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant) +db/ # Schema + seed data (connectors moved to tooling/db/) .claude/ # Agents, skills, rules decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected) ``` @@ -42,7 +42,7 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha ### Before starting work 1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks -2. Use `db/connectors/ticket show <id>` for full ticket details +2. Use `tooling/db/ticket show <id>` for full ticket details 3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing 4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/` @@ -52,17 +52,19 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha | Tool | Command | Full reference | |------|---------|----------------| -| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill | -| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill | -| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — | -| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — | -| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill | -| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill | +| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill | +| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill | +| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — | +| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — | +| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — | +| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill | +| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill | ### File conventions - Decisions: domain files in `decisions/` (see `decisions/README.md` for index) - Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected) +- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across worktrees - Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified. - Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete - Briefings: one per agent, updated after decision-producing rounds -- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill +- Tickets: managed via `tooling/db/ticket` CLI or `/ticket` skill diff --git a/Makefile b/Makefile index 6e71150ba..772b8ffb0 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) pre-pr-server pre-pr-client pre-pr-content \ fixtures-client golden-diff golden-update \ checklist-validate checklist-generate \ - perf-baseline debug-schedule + perf-baseline debug-schedule \ + test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark # --- Configuration --- @@ -23,11 +24,15 @@ help: @echo " make stop Stop any running server instance" @echo " make client Run the Godot client (test mode)" @echo " make server Run the Rust simulation server" - @echo " make test Run all tests" - @echo " make lint Run all linters" - @echo " make ci Run full CI pipeline locally" - @echo " make ci-client Run client CI checks" - @echo " make ci-server Run server CI checks" + @echo " make test Run all tests" + @echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures" + @echo " make test-ipc-protocol Layer 2: mock IPC protocol tests" + @echo " make test-ipc-integration Layer 3: real subprocess round-trip" + @echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)" + @echo " make lint Run all linters" + @echo " make ci Run full CI pipeline locally" + @echo " make ci-client Run client CI checks" + @echo " make ci-server Run server CI checks" @echo " make check-protocol Verify server/client protocol versions match" @echo " make clean Remove build artifacts and caches" @echo "" @@ -128,7 +133,7 @@ stop: test: test-server test-client test-server: - cd server && cargo nextest run + tests/run-rust fixtures: cd server && cargo test --test gen_fixtures -- --ignored @@ -169,8 +174,19 @@ golden-update: @echo "Review with: git diff --cached -- server/tests/golden/" test-client: - @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } - $(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/ + tests/run-godot + +test-ipc-fixtures: + tests/run-ipc-fixtures + +test-ipc-protocol: + tests/run-ipc-protocol + +test-ipc-integration: + tests/run-ipc-integration + +test-ipc-benchmark: + tests/run-ipc-benchmark # --- Lint --- @@ -268,16 +284,16 @@ db-install: # --- Decisions --- decisions-sync: - @db/connectors/decisions-sync + @tooling/db/decisions-sync decisions-coverage: - @db/connectors/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain" + @tooling/db/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain" decisions-active: - @db/connectors/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id" + @tooling/db/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id" decisions-orphan: - @db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)" + @tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)" # --- Content Validation --- diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml index 79bac0ad3..4f1f8b581 100644 --- a/client/data/ui-strings.yaml +++ b/client/data/ui-strings.yaml @@ -104,8 +104,12 @@ notifications: # System save_complete: "Progress saved." + load_complete: "Session restored." + save_failed: "Save failed." + load_failed: "Load failed." connection_lost: "Signal interrupted." connection_restored: "Signal restored." + loading: "Resuming..." # ============================================================ # KNOWLEDGE PANEL LABELS @@ -125,6 +129,17 @@ knowledge_panel: confidence_medium: "Likely" confidence_low: "Unconfirmed" confidence_rumor: "Hearsay" + # D-041 KnowledgeConfidence levels — displayed in journal panel + confidence_direct: "Confirmed" + confidence_knowsdetails: "Detailed" + confidence_knowsof: "Known" + confidence_suspects: "Unconfirmed" + # D-041 KnowledgeSource labels — displayed in journal panel + source_directobservation: "Observed" + source_toldby: "Told" + source_heard: "Overheard" + source_inferred: "Inferred" + source_background: "Prior" # ============================================================ # TUTORIAL TEXT (DIEGETIC) @@ -159,6 +174,8 @@ dialogue: menu: pause_title: "Paused" resume: "Resume" + new_game: "New Game" + continue: "Continue" settings: "Settings" save_game: "Save" load_game: "Load" @@ -167,6 +184,9 @@ menu: confirm_quit: "Unsaved progress will be lost." confirm_yes: "Yes" confirm_no: "No" + load_game_browse: "LOAD GAME" + load_game_back: "BACK" + load_game_empty: "No saves found." settings: audio_volume: "Volume" diff --git a/client/project.godot b/client/project.godot index 8aa99cf2f..486c553c6 100644 --- a/client/project.godot +++ b/client/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="The Settled Reach" -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" @@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd" UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +SessionManager="*res://scripts/autoloads/session_manager.gd" [audio] @@ -125,11 +126,26 @@ debug_overlay={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +open_journal={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) +] +} teleport_hub={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +quicksave={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +quickload={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 5362fca82..e3d7600d6 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=24 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=27 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -21,8 +21,11 @@ [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [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="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"] +[ext_resource type="Script" path="res://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"] +[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -134,6 +137,15 @@ layer = 10 ; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI [node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")] +; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6) +[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")] + +; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent) +[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")] + +; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041) +[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")] + ; --- UI layer (CanvasLayer 20) --- ; HUD, monologue, cursor — always visible, not affected by fog or camera. [node name="UILayer" type="CanvasLayer" parent="."] @@ -141,8 +153,6 @@ layer = 20 [node name="HUD" parent="UILayer" instance=ExtResource("6_hud")] -[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")] - [node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")] ; D-053: Stance indicator — top-right, color-coded @@ -181,3 +191,6 @@ layer = 30 ; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle [node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")] + +; #257: Loading screen — full-screen overlay during save/load round-trip +[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")] diff --git a/client/scenes/main_menu.tscn b/client/scenes/main_menu.tscn new file mode 100644 index 000000000..09afdc2d1 --- /dev/null +++ b/client/scenes/main_menu.tscn @@ -0,0 +1,124 @@ +[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="LoadGameBtn" type="Button" parent="VBox"] +layout_mode = 2 +text = "LOAD GAME" +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) + +[node name="LoadGamePanel" type="Control" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +visible = false + +[node name="PanelBg" type="ColorRect" parent="LoadGamePanel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.05, 0.05, 0.08, 0.96) +mouse_filter = 2 + +[node name="VBox" type="VBoxContainer" parent="LoadGamePanel"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -160.0 +offset_top = -180.0 +offset_right = 160.0 +offset_bottom = 180.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="LoadGamePanel/VBox"] +layout_mode = 2 +text = "LOAD GAME" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 20 +theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0) + +[node name="SavesScroll" type="ScrollContainer" parent="LoadGamePanel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(320, 240) + +[node name="SavesList" type="VBoxContainer" parent="LoadGamePanel/VBox/SavesScroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="BackBtn" type="Button" parent="LoadGamePanel/VBox"] +layout_mode = 2 +text = "BACK" +theme_override_font_sizes/font_size = 14 +theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 979008f4a..a8a66f64c 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -1,9 +1,19 @@ extends Node +signal game_id_changed(new_id: String) + # Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}). # Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}. # Tiles use format: [{x, y, z, type}]. var current_snapshot: Dictionary = {} + +# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6> +# Set by SessionManager.new_game() or SessionManager.resume_game(). +# Empty string when no session is active (main menu state). +var current_game_id: String = "": + set(v): + current_game_id = v + game_id_changed.emit(v) var current_tick: int = 0 var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] @@ -60,6 +70,16 @@ var insert_active: bool = true # Null in v0.1 (server does not yet send this field; protocol change required). var rng_seed: Variant = null +# v15 fields (#554, D-085): save/load result from server. +# {success: bool, kind: "save"|"load", error: Variant} or null. +# One-shot: consumed by main.gd after display, then set back to null. +var save_result: Variant = null + +# #257: Pending load path — set by main menu "Load Game" selection. +# main.gd sends LOAD_GAME on startup if non-empty, then clears this field. +# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load. +var pending_load_path: String = "" + # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] @@ -70,6 +90,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id} var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}] var conversation_ended: Array = [] # [{speaker_id, target_id}] +# v10 fields (#151, D-013): Discovered POIs from server (#148/#149). +# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless +# server explicitly sends an empty array (cleared locations are not typical in v0.1). +# Populated from snapshot "poi_list" field — only updated when field present. +var discovered_pois: Array = [] + +# v14 fields (#174, #242): Character-filtered examine result. +# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds. +var current_examine_result: Variant = null + +# v14 fields (#264, D-041): Player knowledge graph dump for journal panel. +# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}], +# facts: [{fact_id, confidence, source, state, acquired_tick}]} +var player_knowledge: Variant = null + # #126, D-018: Medium-range sound events for fog-edge directional indicators. # Format: [{x, y, event_type, range_category}] — server sends current medium events per tick. var medium_sound_events: Array = [] @@ -79,14 +114,17 @@ var medium_sound_events: Array = [] var close_sound_events: Array = [] # 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). 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 # 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. -# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet). +# D-020: Read directly from snapshot "zone_id" field. +# Fallback: client-side tile lookup (deprecated, remove when server populates field). +# Empty string when zone_id field absent. var current_zone_id: String = "" func apply_snapshot(snapshot: Dictionary) -> void: @@ -110,12 +148,19 @@ func apply_snapshot(snapshot: Dictionary) -> void: push_warning("GameState: no Player entity found in %d entities" % [ visible_entities.size()]) - # D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost. - # Compares current player_position against previous snapshot's position. - if player_position == _prev_player_position: - stationary_ticks += 1 + # D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost. + # Prefer server-sent value; fall back to client-side accumulation until server populates. + if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int: + # D-020: direct field assignment from server-authoritative snapshot. + stationary_ticks = snapshot.stationary_ticks 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 # Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles" @@ -240,16 +285,49 @@ func apply_snapshot(snapshot: Dictionary) -> void: medium_sound_events = [] close_sound_events = [] - # D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles - # (populated above from either "tiles" test-mode key or "visible_tiles" live key). - # Must use the member var, not snapshot.visible_tiles, so test mode is covered. - 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 "" + # v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player. + # Accepts "discovered_pois" or "poi_list" key — both map to the same client field. + # Only update if the field is present — absence means "no change since last tick". + if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array: + discovered_pois = snapshot.discovered_pois + elif snapshot.has("poi_list") and snapshot.poi_list is Array: + discovered_pois = snapshot.poi_list + + # v14: examine_result (#174, #242) — character-filtered observation from Examine verb. + if snapshot.has("examine_result") and snapshot.examine_result is Dictionary: + current_examine_result = snapshot.examine_result + else: + current_examine_result = null + + # v15: save_result (#554, D-085) — one-shot save/load confirmation from server. + if snapshot.has("save_result") and snapshot.save_result is Dictionary: + save_result = snapshot.save_result + else: + save_result = null + + # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. + # Only update when field is present (null means no change, server sends when KG changes). + if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: + player_knowledge = snapshot.player_knowledge + + # D-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 # Derives visible_positions when not explicitly provided (real server mode) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 9a51eee07..6128d4b4d 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -19,8 +19,11 @@ enum Action { INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server + OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only SET_FACING, # D-054: facing octant update (no movement) TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) + SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path + LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path } var input_queue: Array[Dictionary] = [] @@ -106,15 +109,29 @@ func _unhandled_input(event: InputEvent) -> void: action = Action.TOGGLE_STANCE_DOWN elif event.is_action_pressed("bug_report"): action = Action.BUG_REPORT + elif event.is_action_pressed("open_journal"): + action = Action.OPEN_JOURNAL elif event.is_action_pressed("teleport_hub"): if GameState.gauntlet_mode: action = Action.TELEPORT_HUB + elif event.is_action_pressed("quicksave"): + action = Action.SAVE_GAME + elif event.is_action_pressed("quickload"): + action = Action.LOAD_GAME if action != -1: - input_queue.append({ + var entry := { "action": action, "timestamp_msec": Time.get_ticks_msec(), - }) + } + # #554: Attach save path for SaveGame/LoadGame actions + if action == Action.SAVE_GAME or action == Action.LOAD_GAME: + var game_id := GameState.current_game_id + if game_id.is_empty(): + get_viewport().set_input_as_handled() + return # No active session — ignore save/load + entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"} + input_queue.append(entry) get_viewport().set_input_as_handled() diff --git a/client/scripts/autoloads/session_manager.gd b/client/scripts/autoloads/session_manager.gd new file mode 100644 index 000000000..87bd3c90d --- /dev/null +++ b/client/scripts/autoloads/session_manager.gd @@ -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 diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index fa91fb3c3..8477282a8 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -1,17 +1,11 @@ extends Node # Connection states -enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR } +enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR } var state: ConnectionState = ConnectionState.DISCONNECTED var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server -var _test_tick: int = 0 -var _test_player_pos: Vector2i = Vector2i(10, 10) -var _test_facing: String = "North" -var _test_input_queue: Array = [] # Queued actions for test mode -var _test_in_dialogue: bool = false # Mock dialogue state (#434) -var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard -var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color +var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client) var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot) var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport @@ -27,23 +21,68 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts var _connect_retries: int = 0 var _retry_timer: float = 0.0 +# Handshake state (#556) +const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds +var _handshake_start_usec: int = 0 + # Signals signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) signal snapshot_received(snapshot: Dictionary) +signal handshake_complete(protocol_version: int) +signal handshake_failed(reason: String) func _ready() -> void: if test_mode: + harness = TestHarness.new() print("SimBridge: Running in test mode (dynamic snapshot)") -# Reset test state — call before tests that use _test_snapshot() + +# -- Test mode proxy API (backward compat for 13+ test files) ------------------ + func reset_test_state() -> void: - _test_tick = 0 - _test_player_pos = Vector2i(10, 10) - _test_facing = "North" - _test_input_queue.clear() - _test_in_dialogue = false - _test_gauntlet_mode = false - _test_npc_relationship = "Unknown" + if harness: harness.reset() + +func _test_snapshot() -> Dictionary: + return harness.snapshot() + +func _test_has_los(from: Vector2i, to: Vector2i) -> bool: + return harness.has_los(from, to) + +var _test_tick: int: + get: return harness.tick if harness else 0 + set(v): + if harness: harness.tick = v + +var _test_player_pos: Vector2i: + get: return harness.player_pos if harness else Vector2i.ZERO + set(v): + if harness: harness.player_pos = v + +var _test_facing: String: + get: return harness.facing if harness else "North" + set(v): + if harness: harness.facing = v + +var _test_in_dialogue: bool: + get: return harness.in_dialogue if harness else false + set(v): + if harness: harness.in_dialogue = v + +var _test_gauntlet_mode: bool: + get: return harness.gauntlet_mode if harness else false + set(v): + if harness: harness.gauntlet_mode = v + +var _test_npc_relationship: String: + get: return harness.npc_relationship if harness else "Unknown" + set(v): + if harness: harness.npc_relationship = v + +var _test_input_queue: Array: + get: return harness.input_queue if harness else [] + + +# -- Connection lifecycle ------------------------------------------------------ # Change connection state and emit signal func _set_state(new_state: ConnectionState) -> void: @@ -66,8 +105,13 @@ func connect_to_sim() -> void: # Spawn server subprocess if not server_path.is_empty(): _server = ServerProcess.new() - # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876") - var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)]) + # Server reads first positional arg as bind address (e.g. "127.0.0.1:9876"). + # D-085 (#258): pass --game-id <id> so server logs use the same session identifier. + var args := ["127.0.0.1:" + str(server_port)] + var game_id: String = GameState.current_game_id + if not game_id.is_empty(): + args.append_array(["--game-id", game_id]) + var pid := _server.start(server_path, args) if pid <= 0: push_error("SimBridge: failed to start server") _set_state(ConnectionState.ERROR) @@ -121,7 +165,8 @@ func _process(delta: float) -> void: _bridge.poll() match _bridge.get_status(): StreamPeerTCP.STATUS_CONNECTED: - _set_state(ConnectionState.CONNECTED) + _handshake_start_usec = Time.get_ticks_usec() + _set_state(ConnectionState.HANDSHAKING) StreamPeerTCP.STATUS_CONNECTING: pass # Still connecting, wait StreamPeerTCP.STATUS_ERROR: @@ -134,6 +179,62 @@ func _process(delta: float) -> void: _bridge = null # Reset and retry return + # HANDSHAKING state: read first framed message, validate HandshakeMessage (#556) + if state == ConnectionState.HANDSHAKING: + if _bridge == null: + _set_state(ConnectionState.ERROR) + return + _bridge.poll() + + # Check connection dropped during handshake + var bridge_status := _bridge.get_status() + if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE: + var reason := "Connection dropped during handshake" + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge = null + _set_state(ConnectionState.ERROR) + return + + # Check timeout + if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC: + var reason := "Handshake timeout: no message received within 5 seconds" + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge.disconnect_from_server() + _set_state(ConnectionState.ERROR) + return + + # Try to read first message + var msg := _bridge.poll_message() + if msg.is_empty(): + return # Not ready yet, continue polling + + # Decode HandshakeMessage: { "protocol_version": N } + var decoded: Variant = Messagepack.decode(msg) + if decoded.status != null or not (decoded.value is Dictionary) \ + or not decoded.value.has("protocol_version"): + var reason := "Handshake decode failed: malformed HandshakeMessage" + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge.disconnect_from_server() + _set_state(ConnectionState.ERROR) + return + + var server_version: int = decoded.value["protocol_version"] + if server_version != Protocol.PROTOCOL_VERSION: + var reason := "Protocol version mismatch: server=%d, client=%d" % [ + server_version, Protocol.PROTOCOL_VERSION] + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge.disconnect_from_server() + _set_state(ConnectionState.ERROR) + return + + handshake_complete.emit(server_version) + _set_state(ConnectionState.CONNECTED) + return + if _bridge == null: return @@ -170,9 +271,13 @@ func _process(delta: float) -> void: push_warning("SimBridge: connection lost") _set_state(ConnectionState.DISCONNECTED) + +# -- Input / snapshot ---------------------------------------------------------- + # Send input to simulation server. # player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec". -# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport. +# In test mode, inputs are delegated to the test harness. +# In live mode, encoded and buffered for transport. # Returns OK on success, or an error code on failure. func send_input(player_input: Dictionary) -> Error: if state != ConnectionState.CONNECTED: @@ -182,25 +287,20 @@ func send_input(player_input: Dictionary) -> Error: var wire_name: String = action_enum_to_wire(action) if not wire_name.is_empty(): if wire_name == "SetFacing": - # D-054: Use action_data.facing from the input dict, not InputMapper global var facing: String = "" var action_data: Variant = player_input.get("action_data") if action_data is Dictionary: facing = str(action_data.get("facing", "")) if not facing.is_empty(): - _test_facing = facing + harness.process_facing(facing) else: - _test_input_queue.append(wire_name) + harness.process_input(wire_name) return OK var action_name := action_enum_to_wire(player_input.get("action", -1)) if action_name.is_empty(): - # action_enum_to_wire already emits push_warning for invalid actions return ERR_INVALID_PARAMETER - # Use the server's current tick so drain_for_tick processes this input immediately. - # The client-side timestamp_msec is only useful for ordering within a frame. var tick: int = GameState.current_tick var entry: Dictionary = { "tick": tick, "action_name": action_name } - # Data variants (e.g. UsePerceptionMode) carry payload var action_data: Variant = player_input.get("action_data") if action_data != null: entry["action_data"] = action_data @@ -208,13 +308,13 @@ func send_input(player_input: Dictionary) -> Error: return OK # Poll for snapshot from simulation. -# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any). +# In test mode delegates to test harness. In live mode, returns the last decoded snapshot. func poll_snapshot() -> Variant: if state != ConnectionState.CONNECTED: return null if test_mode: - var snapshot = _test_snapshot() + var snapshot = harness.snapshot() snapshot_received.emit(snapshot) return snapshot @@ -252,6 +352,9 @@ func receive_bytes(bytes: PackedByteArray) -> void: if old_conv_ended.size() > 0: var new_conv_ended: Array = snapshot.get("conversation_ended", []) snapshot["conversation_ended"] = old_conv_ended + new_conv_ended + # #554: Carry forward save/load result (one-shot, consumed by main.gd) + if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null: + snapshot["save_result"] = _last_snapshot["save_result"] _last_snapshot = snapshot # Drain the outbound buffer. Returns raw input entries for batch encoding. @@ -260,6 +363,9 @@ func drain_outbound() -> Array[Dictionary]: _outbound_buffer.clear() return inputs + +# -- Wire protocol mapping ----------------------------------------------------- + # Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction). # OPEN_MENU is client-only — no Rust equivalent, not sent over the wire. static func action_enum_to_wire(action: int) -> String: @@ -286,319 +392,10 @@ static func action_enum_to_wire(action: int) -> String: return "SetFacing" # D-054: facing octant update (no movement) InputMapper.Action.TELEPORT_HUB: return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel) + InputMapper.Action.SAVE_GAME: + return "SaveGame" # #554: F5 quicksave (D-085) + InputMapper.Action.LOAD_GAME: + return "LoadGame" # #554: F6 quickload (D-085) _: push_warning("SimBridge: unknown action enum %s" % action) return "" - -# Dynamic test snapshot — processes queued inputs to move player, generates -# visibility based on current position. Matches Protocol.decode_snapshot() format. -# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10) -# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at -# 16,13; wall at 16,14). This ensures standalone tests don't depend on server -# map layout and can exercise the rendering pipeline independently. -func _test_snapshot() -> Dictionary: - _test_tick += 1 - - # Process queued inputs - for action_name in _test_input_queue: - if action_name == "TeleportToHub": - # #501: Reset to hub spawn position, clear dialogue - _test_player_pos = Vector2i(10, 10) - _test_in_dialogue = false - continue - if action_name == "Interact": - # Mock dialogue trigger (#434): if near NPC, start dialogue - var npc_pos := Vector2i(12, 9) - var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y) - if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): - _test_in_dialogue = true - continue - var delta := _action_to_delta(action_name) - var new_pos := _test_player_pos + delta - if _test_is_walkable(new_pos): - _test_player_pos = new_pos - if delta != Vector2i.ZERO: - # Walk-away dismisses dialogue (D-064) - if _test_in_dialogue: - _test_in_dialogue = false - _test_input_queue.clear() - - var px := _test_player_pos.x - var py := _test_player_pos.y - - # Build entities — player always visible - var entities: Array = [{ - "entity_id": 1, - "x": float(px), - "y": float(py), - "z": 0, - "kind": { "variant": "Player", "data": null }, - "visibility": "Forward", - }] - - # NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10) - var npc_pos := Vector2i(12, 9) - var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y) - if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos): - var sector: String = "Forward" if npc_pos.y <= py else "Peripheral" - entities.append({ - "entity_id": 2, - "x": float(npc_pos.x), - "y": float(npc_pos.y), - "z": 0, - "kind": { "variant": "Npc", "data": null }, - "visibility": sector, - "relationship": _test_npc_relationship, - }) - - # v4: nearby_interactions when NPC is nearby and visible (#404/#405) - var nearby: Array = [] - if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos): - nearby.append({ - "entity_id": 2, - "entity_type": "Npc", - "distance": npc_dist, - "verbs": [ - {"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, - {"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, - ], - }) - - # v5: monologue on first tick (#414) - var monologue: Variant = null - if _test_tick == 1: - monologue = { - "id": "test_enter_001", - "text": "Sova Transit District. Population twelve thousand and change.", - "duration_seconds": 5.0, - } - - # v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC - # Sustained: dialogue persists across ticks while _test_in_dialogue is true. - # Movement (walk-away) clears it. Client consume-once guards against re-show. - # Options: structured {text, response_id, priority} per #435. - var dialogue: Variant = null - if _test_in_dialogue: - dialogue = { - "npc_name": "Kael", - "npc_entity_id": 2, - "speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", - "options": [ - {"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, - {"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, - {"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, - ], - } - - # v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity - # Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks. - # Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters). - var pending_recs: Array = [] - var cycle_pos := _test_tick % 12 - if cycle_pos < 6: - var total_delay := 6 - var remaining := total_delay - cycle_pos - pending_recs.append({ - "entity_id": 100, - "x": 13.5, - "y": 12.5, - "z": 0, - "remaining_ticks": remaining, - "total_delay_ticks": total_delay, - }) - - # #535: Mock overheard NPC-NPC conversation (D-078) - # Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3. - # Conversation ends after 6 exchanges (~30 ticks). - var conv_events: Array = [] - var conv_ended: Array = [] - var conv_start := 3 - var conv_lines := [ - {"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."}, - {"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."}, - {"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, - {"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, - {"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, - {"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."}, - ] - var conv_tick_interval := 5 - var conv_total_ticks := conv_lines.size() * conv_tick_interval - if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks: - var conv_index := (_test_tick - conv_start) / conv_tick_interval - var within_tick := (_test_tick - conv_start) % conv_tick_interval - if within_tick == 0 and conv_index < conv_lines.size(): - var cl: Dictionary = conv_lines[conv_index] - conv_events.append({ - "speaker_id": 10, - "target_id": 11, - "speaker_name": cl.speaker, - "target_name": cl.target, - "occluded_line": cl.line, - }) - elif _test_tick == conv_start + conv_total_ticks: - conv_ended.append({"speaker_id": 10, "target_id": 11}) - - return { - "tick": _test_tick, - "version": Protocol.PROTOCOL_VERSION, - "game_time": { - "day": 0, - "time_of_day": _test_tick * 10, - "day_phase": "Morning", - "tick_rate": "Full", - }, - "player_facing": _test_facing, - "player_stance": "Walk", - "player_inventory": [], - "entities": entities, - "tiles": _test_tiles(), - "visible_tiles": _test_visible_tiles(), - "visible_positions": _test_visible_positions(), - "nearby_interactions": nearby, - "current_monologue": monologue, - "current_dialogue": dialogue, - "pending_recognitions": pending_recs, - "gauntlet_mode": _test_gauntlet_mode, - "conversation_events": conv_events, - "conversation_ended": conv_ended, - } - -# Generate a small test room: 8x6 room with walls, a door, and floor -func _test_tiles() -> Array: - var tiles: Array = [] - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(room_x, room_x + room_w): - for y in range(room_y, room_y + room_h): - var is_edge := (x == room_x or x == room_x + room_w - 1 - or y == room_y or y == room_y + room_h - 1) - var tile_type: String - if is_edge: - # Door on the south wall, center - if y == room_y + room_h - 1 and x == room_x + room_w / 2: - tile_type = "door" - else: - tile_type = "wall" - else: - tile_type = "floor" - tiles.append({"x": x, "y": y, "z": 0, "type": tile_type}) - - # Corridor south of the door - var door_x := room_x + room_w / 2 - for y in range(room_y + room_h, room_y + room_h + 4): - tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"}) - tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"}) - tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"}) - - return tiles - -# Test visible tiles with visibility sectors (v2 format) -# Tiles ahead of the player are Forward, others Peripheral. -func _test_visible_tiles() -> Array: - var vtiles: Array = [] - var px := _test_player_pos.x - var py := _test_player_pos.y - var radius := 4 - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(px - radius, px + radius + 1): - for y in range(py - radius, py + radius + 1): - var dist := absf(x - px) + absf(y - py) - if dist <= radius: - if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: - var sector: String = "Forward" if y <= py else "Peripheral" - vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector}) - return vtiles - -# Test visibility: tiles within radius 4 of player, inside room bounds -func _test_visible_positions() -> Array: - var positions: Array = [] - var px := _test_player_pos.x - var py := _test_player_pos.y - var radius := 4 - var room_x := 7 - var room_y := 7 - var room_w := 8 - var room_h := 8 - - for x in range(px - radius, px + radius + 1): - for y in range(py - radius, py + radius + 1): - var dist := absf(x - px) + absf(y - py) - if dist <= radius: - if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: - positions.append({"x": x, "y": y}) - return positions - - -# -- Test mode helpers -- - -const _TEST_WALLS: Array = [ - # Room walls (8x8 room from (7,7) to (14,14)) - Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7), - Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7), - Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14), - Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14), - Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11), - Vector2i(7,12), Vector2i(7,13), - Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11), - Vector2i(14,12), Vector2i(14,13), - # Interior wall blocking NPC - Vector2i(12, 10), -] - -func _test_is_walkable(pos: Vector2i) -> bool: - return not _TEST_WALLS.has(pos) - -# Simple LOS check — blocked if a wall tile sits between start and end -func _test_has_los(from: Vector2i, to: Vector2i) -> bool: - # Bresenham-lite: check tiles along the line - var dx := absi(to.x - from.x) - var dy := absi(to.y - from.y) - var sx := 1 if from.x < to.x else -1 - var sy := 1 if from.y < to.y else -1 - var err := dx - dy - var cx := from.x - var cy := from.y - while true: - if cx == to.x and cy == to.y: - return true - if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)): - return false - var e2 := 2 * err - if e2 > -dy: - err -= dy - cx += sx - if e2 < dx: - err += dx - cy += sy - return true - -static func _action_to_delta(action_name: String) -> Vector2i: - match action_name: - "MoveNorth": return Vector2i(0, -1) - "MoveNortheast": return Vector2i(1, -1) - "MoveEast": return Vector2i(1, 0) - "MoveSoutheast": return Vector2i(1, 1) - "MoveSouth": return Vector2i(0, 1) - "MoveSouthwest": return Vector2i(-1, 1) - "MoveWest": return Vector2i(-1, 0) - "MoveNorthwest": return Vector2i(-1, -1) - _: return Vector2i.ZERO - -static func _delta_to_facing(delta: Vector2i) -> String: - match delta: - Vector2i(0, -1): return "North" - Vector2i(1, -1): return "Northeast" - Vector2i(1, 0): return "East" - Vector2i(1, 1): return "Southeast" - Vector2i(0, 1): return "South" - Vector2i(-1, 1): return "Southwest" - Vector2i(-1, 0): return "West" - Vector2i(-1, -1): return "Northwest" - _: return "North" diff --git a/client/scripts/autoloads/ui_strings.gd b/client/scripts/autoloads/ui_strings.gd index b7c2dd443..95dd4e6d5 100644 --- a/client/scripts/autoloads/ui_strings.gd +++ b/client/scripts/autoloads/ui_strings.gd @@ -49,44 +49,6 @@ func reload() -> void: ## Parse YAML with arbitrary nesting depth. ## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }. +## Delegates to YamlParser.parse_flat() (#560). static func _parse_yaml(text: String) -> Dictionary: - var strings := {} - 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 + return YamlParser.parse_flat(text) diff --git a/client/scripts/checklist/checklist_evaluator.gd b/client/scripts/checklist/checklist_evaluator.gd index 2f51187ca..2e2858721 100644 --- a/client/scripts/checklist/checklist_evaluator.gd +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool: return false -# -- YAML parsing (checklist-specific) ----------------------------------------- -# 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. +# -- YAML parsing -------------------------------------------------------------- func _load_checklist_file(path: String) -> Dictionary: if not FileAccess.file_exists(path): @@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary: return parse_checklist_yaml(text) +## Delegates to YamlParser.parse() (#560). static func parse_checklist_yaml(text: String) -> Dictionary: - var result := {} - 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 + return YamlParser.parse(text) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 9c68ba529..934423248 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -15,9 +15,13 @@ extends Node2D @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031) +@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049) +@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay +@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041) @onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) +@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution @@ -30,6 +34,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 _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 _router: SnapshotEventRouter # #559: callable-based snapshot dispatch const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates @@ -45,6 +50,17 @@ func _ready() -> void: # Connect to simulation (test mode sets CONNECTED immediately) SimBridge.connect_to_sim() + # #257: If returning from main menu "Load Game" selection, defer dispatch until connected. + # In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately. + # In live mode, state is CONNECTING — signal handler dispatches once connected. + if not GameState.pending_load_path.is_empty(): + if loading_screen: + loading_screen.show_loading() + if SimBridge.state == SimBridge.ConnectionState.CONNECTED: + _dispatch_pending_load() + else: + SimBridge.connection_state_changed.connect(_on_sim_connected_for_load) + # Camera anchor: snap to player position before the first frame renders. # In test mode poll_snapshot() returns synchronously — position is set # immediately. In live mode the snapshot isn't available yet — _process @@ -62,11 +78,51 @@ func _ready() -> void: dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue) dialogue_box.pause_requested.connect(_on_dialogue_pause_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 if gauntlet_hud: 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: # Main game loop: poll snapshot, apply state, flush input @@ -86,80 +142,9 @@ func _process(delta: float) -> void: camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true - # Update renderers with new state - if world_renderer and world_renderer.has_method("update_from_state"): - world_renderer.update_from_state() - - # 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_list() - 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() - - # #263: Update time display (D-013, D-031) - if time_display and time_display.has_method("update_from_state"): - time_display.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() + # #559: Dispatch snapshot to registered handlers (router pattern). + # Always-run handlers update child nodes; keyed handlers fire for present fields. + _router.dispatch(snapshot) # Track camera to player (D-015: locked, fixed-north). # #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED. @@ -184,6 +169,19 @@ func _process(delta: float) -> void: if bug_report_dialog and not bug_report_dialog.is_active(): bug_report_dialog.start_capture() continue + # #264: J — client-only, toggle knowledge journal panel + if input.action == InputMapper.Action.OPEN_JOURNAL: + _toggle_journal() + continue + # #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails) + if input.action == InputMapper.Action.LOAD_GAME: + var err := SimBridge.send_input(input) + _pending_record_inputs.append(input) + if err == OK and loading_screen: + loading_screen.show_loading() + elif err != OK: + push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err)) + continue # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog if input.action == InputMapper.Action.OPEN_MENU: if settings_dialog: @@ -228,6 +226,32 @@ func _process(delta: float) -> void: _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. # 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). @@ -318,6 +342,9 @@ func _consume_dialogue() -> void: GameState.current_dialogue = null return _last_dialogue_tick = GameState.current_tick + # #264: Close journal when dialogue opens (cannot be open simultaneously) + if journal_panel and journal_panel.has_method("close"): + journal_panel.close() var dlg: Dictionary = GameState.current_dialogue _last_dialogue_npc_id = dlg.get("npc_entity_id", -1) _last_dialogue_npc_name = dlg.get("npc_name", "") @@ -362,6 +389,30 @@ func _consume_dialogue_response() -> void: GameState.dialogue_response = null +# #554/#257: Show save/load result notification; hide loading screen on load complete. +func _consume_save_result() -> void: + if GameState.save_result == null: + return + var result: Dictionary = GameState.save_result + GameState.save_result = null # consume once + # #257: Dismiss loading screen regardless of success/failure + if loading_screen: + loading_screen.hide_loading() + var msg: String + if result.get("success", false): + if result.get("kind", "") == "save": + msg = UIStrings.get_text("notifications.save_complete") + else: + msg = UIStrings.get_text("notifications.load_complete") + else: + if result.get("kind", "") == "save": + msg = UIStrings.get_text("notifications.save_failed") + else: + msg = UIStrings.get_text("notifications.load_failed") + if monologue_display: + monologue_display.show_notification(msg) + + # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ @@ -413,12 +464,54 @@ 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 func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud: gauntlet_hud.finalize() +# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED. +# pending_load_path is set by main_menu.gd before scene change. +func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: + if new_state != SimBridge.ConnectionState.CONNECTED: + return + if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load): + SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load) + _dispatch_pending_load() + + +func _dispatch_pending_load() -> void: + var load_path := GameState.pending_load_path + if load_path.is_empty(): + return + GameState.pending_load_path = "" + var err := SimBridge.send_input({ + "action": InputMapper.Action.LOAD_GAME, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": {"path": load_path}, + }) + if err != OK: + push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err)) + if loading_screen: + loading_screen.hide_loading(false) + + # #501: Detect large position jump indicating a teleport (not normal movement). const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 @@ -456,6 +549,33 @@ func _teleport_transition() -> void: tween.tween_callback(_flash_rect.queue_free) +# #174: Consume examine result — show overlay when server sends character-filtered observation. +# Clears after display (single-consume). Dismiss examine when dialogue opens. +func _consume_examine_result() -> void: + if GameState.current_examine_result == null or not examine_display: + return + var result: Dictionary = GameState.current_examine_result + # Dismiss existing examine result if dialogue is active (focus priority) + if dialogue_box and dialogue_box.is_dialogue_active(): + if examine_display.has_method("dismiss"): + examine_display.dismiss() + else: + if examine_display.has_method("show_result"): + examine_display.show_result(result) + GameState.current_examine_result = null + + +# #264: Toggle journal panel. Called from input handler when J key pressed. +func _toggle_journal() -> void: + if not journal_panel: + return + # Journal and dialogue cannot be open simultaneously (sprint briefing) + if dialogue_box and dialogue_box.is_dialogue_active(): + return + if journal_panel.has_method("toggle"): + journal_panel.toggle() + + # #502: Full-screen color flash — fades from color to transparent over duration. # Used for room reset amber flash. Creates ephemeral ColorRect on UILayer. func _screen_flash(color: Color, duration: float) -> void: diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 492dc219d..81ada4e2c 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 13 +const PROTOCOL_VERSION: int = 16 # -- 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)), }) + # 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 { "tick": tick, "entities": entities, @@ -223,6 +316,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "pending_recognitions": pending_recognitions, "conversation_events": conversation_events, "conversation_ended": conversation_ended, + "poi_list": poi_list, + "examine_result": examine_result, + "player_knowledge": player_knowledge, + "save_result": save_result, + "stationary_ticks": stationary_ticks, + "zone_id": zone_id, } diff --git a/client/scripts/protocol/test_harness.gd b/client/scripts/protocol/test_harness.gd new file mode 100644 index 000000000..d75b6f8cf --- /dev/null +++ b/client/scripts/protocol/test_harness.gd @@ -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" diff --git a/client/scripts/snapshot_event_router.gd b/client/scripts/snapshot_event_router.gd new file mode 100644 index 000000000..334f7e318 --- /dev/null +++ b/client/scripts/snapshot_event_router.gd @@ -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() diff --git a/client/scripts/ui/debug_overlay.gd b/client/scripts/ui/debug_overlay.gd deleted file mode 100644 index b2f6eb8cb..000000000 --- a/client/scripts/ui/debug_overlay.gd +++ /dev/null @@ -1,115 +0,0 @@ -extends Control -# #511: F3 debug overlay — real-time game state display for dev use. - -const HEADER_COLOR := Color("#e8c547") -const LABEL_COLOR := Color("#8890a0") -const VALUE_COLOR := Color("#c8d0e0") -const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85) -const FONT_SIZE := 12 -const LINE_HEIGHT := 16 -const PADDING := Vector2(10, 8) -const COL_GAP := 16 # gap between left and right columns - -var _cached_font: Font = null - -func _ready() -> void: - visible = false - _cached_font = ThemeDB.fallback_font - -func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("debug_overlay"): - visible = not visible - if visible: - queue_redraw() - -func update_from_state() -> void: - if not visible: - return - queue_redraw() - -func _draw() -> void: - if not visible: - return - var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font - - # Build lines as [label, value, label, value] pairs (two columns) - var left_lines: Array = [] - var right_lines: Array = [] - - left_lines.append(["tick", str(GameState.current_tick)]) - right_lines.append(["fps", str(Engine.get_frames_per_second())]) - - var pos := GameState.player_position - left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]]) - right_lines.append(["facing", GameState.player_facing]) - - left_lines.append(["stance", GameState.player_stance]) - right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"]) - - left_lines.append(["entities", str(GameState.visible_entities.size())]) - right_lines.append(["tiles", str(GameState.visible_tiles.size())]) - - left_lines.append(["interactions", str(GameState.nearby_interactions.size())]) - right_lines.append(["recognitions", str(GameState.pending_recognitions.size())]) - - var mono_status := "active" if GameState.current_monologue != null else "idle" - var dlg_status := "active" if GameState.dialogue_active else "idle" - left_lines.append(["monologue", mono_status]) - right_lines.append(["dialogue", dlg_status]) - - left_lines.append(["stationary", str(GameState.stationary_ticks)]) - right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"]) - - var gt := GameState.game_time - var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-" - var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-" - left_lines.append(["time", time_str]) - right_lines.append(["tick_rate", rate_str]) - - var mode_str := "test" if SimBridge.test_mode else "live" - var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF" - left_lines.append(["mode", mode_str]) - right_lines.append(["gauntlet", gauntlet_str]) - - # Measure column widths - var left_label_w: float = 0.0 - var left_value_w: float = 0.0 - var right_label_w: float = 0.0 - var right_value_w: float = 0.0 - - for line in left_lines: - left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) - left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) - for line in right_lines: - right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) - right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) - - var header_text := "F3 DEBUG" - var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x - var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w - var box_w: float = max(header_w, content_w) + PADDING.x * 2 - var line_count: int = maxi(left_lines.size(), right_lines.size()) - var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines - - # Background - draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR) - - # Header - var y: float = PADDING.y + FONT_SIZE - draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR) - y += LINE_HEIGHT - - # Data lines (two columns) - var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP - for i in range(line_count): - if i < left_lines.size(): - var lbl: String = left_lines[i][0] + ": " - var val: String = left_lines[i][1] - draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) - draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) - if i < right_lines.size(): - var lbl: String = right_lines[i][0] + ": " - var val: String = right_lines[i][1] - draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) - draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) - y += LINE_HEIGHT diff --git a/client/scripts/util/yaml_parser.gd b/client/scripts/util/yaml_parser.gd new file mode 100644 index 000000000..cafec640e --- /dev/null +++ b/client/scripts/util/yaml_parser.gd @@ -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 diff --git a/client/tests/fixtures/msgpack/malformed.msgpack b/client/tests/fixtures/msgpack/malformed.msgpack new file mode 100644 index 000000000..bd2e3507c --- /dev/null +++ b/client/tests/fixtures/msgpack/malformed.msgpack @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/player_input_interact.msgpack b/client/tests/fixtures/msgpack/player_input_interact.msgpack new file mode 100644 index 000000000..c2963fa57 --- /dev/null +++ b/client/tests/fixtures/msgpack/player_input_interact.msgpack @@ -0,0 +1 @@ +tickactionInteracttarget_entity_idcverbTalk \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/player_input_move.msgpack b/client/tests/fixtures/msgpack/player_input_move.msgpack new file mode 100644 index 000000000..2d733e444 --- /dev/null +++ b/client/tests/fixtures/msgpack/player_input_move.msgpack @@ -0,0 +1 @@ +tickactionMoveNorth \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 0a0876c58..1615131dc 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 6aafb8c2c..2d4321df5 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 7d6d91200..4a8702760 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index cd39ea41f..a1c311896 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index a195fdf6b..47762a0df 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 0a0876c58..1615131dc 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack new file mode 100644 index 000000000..d2f5bbd60 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack new file mode 100644 index 000000000..fba288bb8 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index a332c6c50..dc2acf96a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 0093a3ac1..107389317 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index e919024ce..8166124b0 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index b7d86d7de..f83ad4838 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/client/tests/run_gdunit4.gd b/client/tests/run_gdunit4.gd new file mode 100644 index 000000000..52aab6e23 --- /dev/null +++ b/client/tests/run_gdunit4.gd @@ -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) diff --git a/client/tests/test_debug_overlay_sprint19.gd b/client/tests/test_debug_overlay_sprint19.gd new file mode 100644 index 000000000..1661dcaed --- /dev/null +++ b/client/tests/test_debug_overlay_sprint19.gd @@ -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://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://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() diff --git a/client/tests/test_dialogue_sprint18.gd b/client/tests/test_dialogue_sprint18.gd new file mode 100644 index 000000000..025f3a30c --- /dev/null +++ b/client/tests/test_dialogue_sprint18.gd @@ -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 diff --git a/client/tests/test_dialogue_sprint20.gd b/client/tests/test_dialogue_sprint20.gd new file mode 100644 index 000000000..b3f580dae --- /dev/null +++ b/client/tests/test_dialogue_sprint20.gd @@ -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() diff --git a/client/tests/test_examine_display_sprint18.gd b/client/tests/test_examine_display_sprint18.gd new file mode 100644 index 000000000..d5479e9d3 --- /dev/null +++ b/client/tests/test_examine_display_sprint18.gd @@ -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() diff --git a/client/tests/test_game_state.gd b/client/tests/test_game_state.gd new file mode 100644 index 000000000..aebd84461 --- /dev/null +++ b/client/tests/test_game_state.gd @@ -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() diff --git a/client/tests/test_game_state_sprint20.gd b/client/tests/test_game_state_sprint20.gd new file mode 100644 index 000000000..7c302a03d --- /dev/null +++ b/client/tests/test_game_state_sprint20.gd @@ -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("") diff --git a/client/tests/test_ipc_fixtures.gd b/client/tests/test_ipc_fixtures.gd new file mode 100644 index 000000000..12594b279 --- /dev/null +++ b/client/tests/test_ipc_fixtures.gd @@ -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" + ) diff --git a/client/tests/test_journal_sprint18.gd b/client/tests/test_journal_sprint18.gd new file mode 100644 index 000000000..ad2a5dd13 --- /dev/null +++ b/client/tests/test_journal_sprint18.gd @@ -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() diff --git a/client/tests/test_minimap_sprint18.gd b/client/tests/test_minimap_sprint18.gd new file mode 100644 index 000000000..5013ed5e6 --- /dev/null +++ b/client/tests/test_minimap_sprint18.gd @@ -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) diff --git a/client/tests/test_save_load_flow_sprint21.gd b/client/tests/test_save_load_flow_sprint21.gd new file mode 100644 index 000000000..6bf433e39 --- /dev/null +++ b/client/tests/test_save_load_flow_sprint21.gd @@ -0,0 +1,110 @@ +## Sprint 21 — Save/load game flow (#257) +## Tests: LoadingScreen overlay, pending_load_path field, deferred dispatch guards. +class_name TestSaveLoadFlowSprint21 +extends GdUnitTestSuite + + +const LOADING_SCREEN_SCENE_PATH: String = "res://ui/loading_screen.tscn" + + +func _make_loading_screen() -> Control: + if not ResourceLoader.exists(LOADING_SCREEN_SCENE_PATH): + push_warning("TestSaveLoadFlowSprint21: loading_screen.tscn not found — skip") + return null + var node: Control = load(LOADING_SCREEN_SCENE_PATH).instantiate() + add_child(node) + return node + + +func before_test() -> void: + GameState.pending_load_path = "" + + +func after_test() -> void: + GameState.pending_load_path = "" + + +# --------------------------------------------------------------------------- +# LoadingScreen scene +# --------------------------------------------------------------------------- + +func test_loading_screen_scene_exists() -> void: + assert_bool(ResourceLoader.exists(LOADING_SCREEN_SCENE_PATH)).override_failure_message( + "LoadingScreen scene must exist at res://ui/loading_screen.tscn (#257)" + ).is_true() + + +func test_loading_screen_hidden_on_ready() -> void: + var ls := _make_loading_screen() + if ls == null: return + assert_bool(ls.visible).override_failure_message( + "LoadingScreen must be hidden on _ready (#257)" + ).is_false() + ls.queue_free() + + +func test_show_loading_makes_visible() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + assert_bool(ls.visible).override_failure_message( + "show_loading() must make LoadingScreen visible" + ).is_true() + ls.queue_free() + + +func test_hide_loading_makes_invisible() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + ls.hide_loading() + assert_bool(ls.visible).override_failure_message( + "hide_loading() must hide LoadingScreen" + ).is_false() + ls.queue_free() + + +func test_hide_loading_accepts_success_param() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + ls.hide_loading(true) + assert_bool(ls.visible).is_false() + ls.show_loading() + ls.hide_loading(false) + assert_bool(ls.visible).is_false() + ls.queue_free() + + +func test_hide_loading_default_param_is_true() -> void: + var ls := _make_loading_screen() + if ls == null: return + ls.show_loading() + ls.hide_loading() # no argument — default success=true + assert_bool(ls.visible).is_false() + ls.queue_free() + + +# --------------------------------------------------------------------------- +# GameState.pending_load_path field +# --------------------------------------------------------------------------- + +func test_pending_load_path_default_is_empty() -> void: + GameState.pending_load_path = "" + assert_str(GameState.pending_load_path).override_failure_message( + "GameState.pending_load_path default must be empty string" + ).is_empty() + + +func test_pending_load_path_can_be_set_and_read() -> void: + var path := "user://saves/20260225-143022-a7b3f1/quicksave.sav" + GameState.pending_load_path = path + assert_str(GameState.pending_load_path).override_failure_message( + "GameState.pending_load_path must persist the value set" + ).is_equal(path) + + +func test_pending_load_path_can_be_cleared() -> void: + GameState.pending_load_path = "user://saves/test/quicksave.sav" + GameState.pending_load_path = "" + assert_str(GameState.pending_load_path).is_empty() diff --git a/client/tests/test_session_manager_sprint19.gd b/client/tests/test_session_manager_sprint19.gd new file mode 100644 index 000000000..ef6f68fd3 --- /dev/null +++ b/client/tests/test_session_manager_sprint19.gd @@ -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") diff --git a/client/tests/test_snapshot_event_router.gd b/client/tests/test_snapshot_event_router.gd new file mode 100644 index 000000000..5d9477b3d --- /dev/null +++ b/client/tests/test_snapshot_event_router.gd @@ -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": []}) diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd index 0efb6929e..6b77cf4a9 100644 --- a/client/tests/test_ui_framework_sprint15.gd +++ b/client/tests/test_ui_framework_sprint15.gd @@ -110,13 +110,14 @@ func test_stance_indicator_exists_in_ui_layer() -> void: func test_minimap_placeholder_exists_in_ui_layer() -> void: - # D-013: Minimap/insert placeholder must be in UILayer (not implemented yet). + # D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer. + # Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec. var scene := load("res://scenes/main.tscn") _instance = scene.instantiate() auto_free(_instance) add_child(_instance) - assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null() + assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null() func test_hud_exists_in_ui_layer() -> void: diff --git a/client/tests/test_yaml_parser.gd b/client/tests/test_yaml_parser.gd new file mode 100644 index 000000000..6897b4da5 --- /dev/null +++ b/client/tests/test_yaml_parser.gd @@ -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) diff --git a/client/tests/util/scene_helper.gd b/client/tests/util/scene_helper.gd new file mode 100644 index 000000000..e0c6a9bb5 --- /dev/null +++ b/client/tests/util/scene_helper.gd @@ -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] diff --git a/client/ui/debug_overlay.gd b/client/ui/debug_overlay.gd new file mode 100644 index 000000000..362e5758d --- /dev/null +++ b/client/ui/debug_overlay.gd @@ -0,0 +1,460 @@ +extends Control +## #348: F3 debug overlay — real-time visualization of game state for dev use. +## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false). +## +## Panels: +## 1. Stats text (top-left): tick, pos, fps, etc. +## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags +## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +const HEADER_COLOR := Color("#e8c547") +const LABEL_COLOR := Color("#8890a0") +const VALUE_COLOR := Color("#c8d0e0") +const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85) +const FONT_SIZE := 12 +const LINE_HEIGHT := 16 +const PADDING := Vector2(10, 8) +const COL_GAP := 16 + +# World overlay colors +const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50) +const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85) +const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12) +const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07) +const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55) +const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75) +const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90) +const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80) +const TAG_TEXT_COLOR := Color("#c8d0e0") +const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82) +const GRAPH_LINE_COLOR := Color("#6bc9a6") +const GRAPH_WARN_COLOR := Color("#e8c547") + +# Vision cone geometry (radians) +# Forward: ±60° around facing direction (120° total) +# Peripheral: ±60° to ±120° on each side (60° band each side) +const CONE_FORWARD_HALF: float = PI / 3.0 # 60° +const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120° +const CONE_ARC_STEPS: int = 20 + +# NPC path history +const NPC_HISTORY_LEN: int = 12 +const NPC_DOT_RADIUS: float = 3.5 +const PLAYER_DOT_RADIUS: float = 5.0 + +# Tick timing graph +const GRAPH_W: float = 160.0 +const GRAPH_H: float = 48.0 +const GRAPH_MARGIN: float = 10.0 +const TICK_HISTORY_LEN: int = 30 +const TICK_WARN_MS: float = 120.0 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var _cached_font: Font = null +var _dev_mode: bool = false + +# NPC path history: entity_id (int) → Array of Vector2 (world positions) +var _npc_paths: Dictionary = {} +var _last_tick_processed: int = -1 + +# Tick timing ring +var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot +var _tick_deltas: Array = [] # ms between consecutive snapshots + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _dev_mode = OS.is_debug_build() + visible = false + _cached_font = ThemeDB.fallback_font + # Clear NPC path history on session change to prevent entity ID collisions + GameState.connect("game_id_changed", _on_game_id_changed) + + +func _on_game_id_changed(_new_id: String) -> void: + _npc_paths.clear() + _tick_deltas.clear() + _tick_times.clear() + _last_tick_processed = -1 + + +func _unhandled_input(event: InputEvent) -> void: + if not _dev_mode: + return + if event.is_action_pressed("debug_overlay"): + visible = not visible + if visible: + queue_redraw() + + +func update_from_state() -> void: + if not _dev_mode or not visible: + return + + # Record tick arrival time for timing graph + var now_ms := Time.get_ticks_msec() + if _last_tick_processed != GameState.current_tick: + _last_tick_processed = GameState.current_tick + if _tick_times.size() > 0: + _tick_deltas.append(float(now_ms - _tick_times.back())) + if _tick_deltas.size() > TICK_HISTORY_LEN: + _tick_deltas.pop_front() + _tick_times.append(now_ms) + if _tick_times.size() > TICK_HISTORY_LEN + 1: + _tick_times.pop_front() + _update_npc_paths() + + queue_redraw() + + +func _update_npc_paths() -> void: + var seen_ids: Dictionary = {} + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + var kind_variant: String = entity.get("kind", {}).get("variant", "") + if kind_variant != "Npc": + continue + var eid: int = entity.get("entity_id", -1) + if eid < 0: + continue + seen_ids[eid] = true + var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0)) + if not _npc_paths.has(eid): + _npc_paths[eid] = [] + var path: Array = _npc_paths[eid] + if path.size() == 0 or path.back() != pos: + path.append(pos) + if path.size() > NPC_HISTORY_LEN: + path.pop_front() + # Prune entities no longer visible + for eid in _npc_paths.keys(): + if not seen_ids.has(eid): + _npc_paths.erase(eid) + + +# --------------------------------------------------------------------------- +# Draw dispatch +# --------------------------------------------------------------------------- + +func _draw() -> void: + if not visible: + return + _draw_stats_panel() + _draw_world_overlays() + _draw_tick_graph() + + +# --------------------------------------------------------------------------- +# Panel 1: Stats text (top-left) +# --------------------------------------------------------------------------- + +func _draw_stats_panel() -> void: + var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font + + var left_lines: Array = [] + var right_lines: Array = [] + + left_lines.append(["tick", str(GameState.current_tick)]) + right_lines.append(["fps", str(Engine.get_frames_per_second())]) + + var pos := GameState.player_position + left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]]) + right_lines.append(["facing", GameState.player_facing]) + + left_lines.append(["stance", GameState.player_stance]) + right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"]) + + left_lines.append(["entities", str(GameState.visible_entities.size())]) + right_lines.append(["tiles", str(GameState.visible_tiles.size())]) + + left_lines.append(["interactions", str(GameState.nearby_interactions.size())]) + right_lines.append(["recognitions", str(GameState.pending_recognitions.size())]) + + var mono_status := "active" if GameState.current_monologue != null else "idle" + var dlg_status := "active" if GameState.dialogue_active else "idle" + left_lines.append(["monologue", mono_status]) + right_lines.append(["dialogue", dlg_status]) + + left_lines.append(["stationary", str(GameState.stationary_ticks)]) + right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"]) + + var gt := GameState.game_time + var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-" + var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-" + left_lines.append(["time", time_str]) + right_lines.append(["tick_rate", rate_str]) + + var mode_str := "test" if SimBridge.test_mode else "live" + var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF" + left_lines.append(["mode", mode_str]) + right_lines.append(["gauntlet", gauntlet_str]) + + var gid := GameState.current_game_id + left_lines.append(["game_id", gid if gid != "" else "-"]) + right_lines.append(["npc_paths", str(_npc_paths.size())]) + + var left_label_w: float = 0.0 + var left_value_w: float = 0.0 + var right_label_w: float = 0.0 + var right_value_w: float = 0.0 + + for line in left_lines: + left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + for line in right_lines: + right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) + + var header_text := "F3 DEBUG" + var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x + var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w + var box_w: float = max(header_w, content_w) + PADDING.x * 2 + var line_count: int = maxi(left_lines.size(), right_lines.size()) + var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count + + draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR) + + var y: float = PADDING.y + FONT_SIZE + draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR) + y += LINE_HEIGHT + + var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP + for i in range(line_count): + if i < left_lines.size(): + draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) + draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) + if i < right_lines.size(): + draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) + draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) + y += LINE_HEIGHT + + +# --------------------------------------------------------------------------- +# Panel 2: World overlays +# --------------------------------------------------------------------------- + +func _draw_world_overlays() -> void: + var vp := get_viewport() + if vp == null: + return + # get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content. + # The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space. + # Using this transform converts world coords → screen pixel coords for the overlays. + var canvas_xf := vp.get_canvas_transform() + var player_screen := _w2s(GameState.player_position, canvas_xf) + + _draw_vision_cone(player_screen, canvas_xf) + _draw_los_rays(player_screen, canvas_xf) + _draw_npc_paths(canvas_xf) + _draw_info_tags(canvas_xf) + + +# Convert world tile position → screen pixel position +func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2: + return canvas_xf * (world_pos * Constants.TILE_SIZE) + + +# Vision cone: filled forward sector + peripheral bands. +# Uses player facing direction and visibility sector distance. +func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void: + var facing_angle := _facing_to_angle(GameState.player_facing) + + # Estimate visible radius from furthest visibility sector tile + var max_d: float = 4.0 + for vpos in GameState.visibility_sectors.keys(): + var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position) + if d > max_d: + max_d = d + var scale_x := canvas_xf.x.length() + var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0) + + # Helper: build a polygon fan from center outward over arc [angle_from, angle_to] + var forward_from := facing_angle - CONE_FORWARD_HALF + var forward_to := facing_angle + CONE_FORWARD_HALF + var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF + var perip_l_to := facing_angle - CONE_FORWARD_HALF + var perip_r_from := facing_angle + CONE_FORWARD_HALF + var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF + + draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR) + draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR) + draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR) + + # Forward arc boundary ring + draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0) + + # Player dot + draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR) + + +# Build a filled polygon fan from center through an arc +func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array: + var pts := PackedVector2Array() + pts.append(center) + for i in range(CONE_ARC_STEPS + 1): + var t := float(i) / float(CONE_ARC_STEPS) + var a := angle_from + t * (angle_to - angle_from) + pts.append(center + Vector2(cos(a), sin(a)) * radius) + return pts + + +# Dashed LOS lines from player to each visible non-player entity +func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void: + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + if entity.get("kind", {}).get("variant", "") == "Player": + continue + var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0)) + var entity_screen := _w2s(entity_world, canvas_xf) + var rel: String = entity.get("relationship", "Unknown") + var color := Constants.color_for_relationship(rel) + color.a = 0.45 + draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0) + draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7)) + + +# Fading NPC movement path trails from position history +func _draw_npc_paths(canvas_xf: Transform2D) -> void: + for eid in _npc_paths.keys(): + var path: Array = _npc_paths[eid] + if path.size() < 2: + continue + for i in range(1, path.size()): + var a_screen := _w2s(path[i - 1], canvas_xf) + var b_screen := _w2s(path[i], canvas_xf) + var alpha := float(i) / float(path.size()) + draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5) + draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR) + + +# Information state tags above visible NPCs from player_knowledge +func _draw_info_tags(canvas_xf: Transform2D) -> void: + if GameState.player_knowledge == null: + return + var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font + var knowledge: Dictionary = GameState.player_knowledge + + # Build entity_id → knowledge entry lookup + var kg_by_id: Dictionary = {} + for entry in knowledge.get("entities", []): + if entry is Dictionary and entry.has("entity_id"): + kg_by_id[entry.entity_id] = entry + + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + if entity.get("kind", {}).get("variant", "") != "Npc": + continue + var eid: int = entity.get("entity_id", -1) + if not kg_by_id.has(eid): + continue + var kg_entry: Dictionary = kg_by_id[eid] + var confidence: String = kg_entry.get("confidence", "Unknown") + var name_str: String = kg_entry.get("name", "?") + var label := "%s [%s]" % [name_str, confidence] + + var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf) + var tag_baseline := entity_screen.y - 18.0 + var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x + var tag_rect := Rect2( + entity_screen.x - text_w / 2.0 - 3.0, + tag_baseline - FONT_SIZE + 2.0, + text_w + 6.0, + FONT_SIZE) + draw_rect(tag_rect, TAG_BG_COLOR) + draw_string(font, + Vector2(entity_screen.x - text_w / 2.0, tag_baseline), + label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR) + + +# --------------------------------------------------------------------------- +# Panel 3: Tick timing sparkline (bottom-left) +# --------------------------------------------------------------------------- + +func _draw_tick_graph() -> void: + if _tick_deltas.size() < 2: + return + var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font + var vp_size := get_viewport_rect().size + var box_x := GRAPH_MARGIN + var label_h := LINE_HEIGHT + var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN + + draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR) + draw_string(font, + Vector2(box_x + 4, box_y + FONT_SIZE + 1), + "tick ms (n=%d)" % _tick_deltas.size(), + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR) + + var chart_top := box_y + label_h + var chart_left := box_x + 4.0 + var chart_w := GRAPH_W - 8.0 + var chart_h := GRAPH_H - 4.0 + + # Max value for scale + var max_ms: float = TICK_WARN_MS + for d in _tick_deltas: + if float(d) > max_ms: + max_ms = float(d) + max_ms *= 1.1 + + # Warn threshold dashed line + var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms) + draw_dashed_line( + Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y), + Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3), + 1.0, 4.0) + + # Sparkline + var n := _tick_deltas.size() + var prev_pt := Vector2.ZERO + for i in range(n): + var x := chart_left + chart_w * (float(i) / float(n - 1)) + var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms) + var y := chart_top + chart_h * (1.0 - clamped / max_ms) + var pt := Vector2(x, y) + var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR + if i > 0: + draw_line(prev_pt, pt, color, 1.5) + draw_circle(pt, 2.0, color) + prev_pt = pt + + # Average label + var avg_ms := 0.0 + for d in _tick_deltas: + avg_ms += float(d) + avg_ms /= float(_tick_deltas.size()) + draw_string(font, + Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2), + "avg %.0fms" % avg_ms, + HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North) +static func _facing_to_angle(facing: String) -> float: + match facing: + "North": return -PI / 2.0 + "Northeast": return -PI / 4.0 + "East": return 0.0 + "Southeast": return PI / 4.0 + "South": return PI / 2.0 + "Southwest": return PI * 3.0 / 4.0 + "West": return PI + "Northwest": return -PI * 3.0 / 4.0 + _: return -PI / 2.0 diff --git a/client/scripts/ui/debug_overlay.gd.uid b/client/ui/debug_overlay.gd.uid similarity index 100% rename from client/scripts/ui/debug_overlay.gd.uid rename to client/ui/debug_overlay.gd.uid diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index bc79227fe..c1b4f2ec1 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -15,6 +15,11 @@ signal dialogue_dismissed # Walk-away or conversation end 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 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 dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog @@ -286,10 +291,10 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi # Show panel _ensure_visible() 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 - AudioManager.apply_dip("dialogue") + # D-069: Dialogue dip — coordinator routes to AudioManager + audio_dip_requested.emit("dialogue") # D-061: auto-pause — signal to main.gd for input recording (#507) pause_requested.emit() @@ -311,14 +316,14 @@ func _end_player_conversation() -> void: entry.timestamp_msec = now _log_dirty = true - # D-069: Clear dialogue/confrontation dip - AudioManager.clear_dip() + # D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager + audio_dip_cleared.emit() # D-061: unpause — signal to main.gd for input recording (#507) unpause_requested.emit() - # D-064: unblock movement immediately — log entries stay visible but don't block input. - GameState.dialogue_active = false + # D-064: unblock movement immediately — coordinator handles GameState update. + dialogue_state_changed.emit(false) # If no entries remain, hide the panel with fade. if _log_entries.is_empty(): @@ -331,11 +336,11 @@ func hide_dialogue() -> void: _end_player_conversation() 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: - return _in_player_conversation or GameState.dialogue_active + return _in_player_conversation func has_active_entries() -> bool: @@ -354,26 +359,45 @@ func _show_options(options: Array) -> void: var raw_text: String = opt.get("text", "") var is_confrontation: bool = opt.get("confrontation", false) - var label := Label.new() - label.add_theme_font_size_override("font_size", 14) - label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - label.mouse_filter = Control.MOUSE_FILTER_STOP - label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - - var numbered_text := "%d. %s" % [i + 1, raw_text] - label.text = numbered_text + # D-063: Confrontation options render italic — first-person voice, weighted differently. + # Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard. + var ctrl: Control + if is_confrontation: + var rtl := RichTextLabel.new() + rtl.bbcode_enabled = true + rtl.fit_content = true + rtl.scroll_active = false + rtl.add_theme_font_size_override("normal_font_size", 14) + rtl.add_theme_color_override("default_color", Color( + Constants.INSERT_COLOR_TEXT.r * 1.08, + Constants.INSERT_COLOR_TEXT.g * 0.96, + Constants.INSERT_COLOR_TEXT.b * 0.90, + 1.0 + )) # Slight warm tint for confrontation weight + rtl.mouse_filter = Control.MOUSE_FILTER_STOP + rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text] + ctrl = rtl + else: + var label := Label.new() + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.mouse_filter = Control.MOUSE_FILTER_STOP + label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + label.text = "%d. %s" % [i + 1, raw_text] + ctrl = label var idx := i - label.gui_input.connect(func(event: InputEvent): + ctrl.gui_input.connect(func(event: InputEvent): if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: _on_option_pressed(idx) ) - label.mouse_entered.connect(_make_hover_on(label)) - label.mouse_exited.connect(_make_hover_off(label)) + ctrl.mouse_entered.connect(_make_hover_on(ctrl)) + ctrl.mouse_exited.connect(_make_hover_off(ctrl)) - options_container.add_child(label) - _option_controls.append(label) + options_container.add_child(ctrl) + _option_controls.append(ctrl) _option_response_ids.append(opt.get("response_id", "")) _option_texts.append(raw_text) _option_is_confrontation.append(is_confrontation) @@ -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) 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.tween_interval(CONFRONTATION_BEAT_DURATION) _beat_tween.tween_callback(func(): - AudioManager.clear_dip() + audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager option_selected.emit(response_id, text) _end_player_conversation() ) @@ -424,7 +448,7 @@ func _cancel_beat() -> void: if _beat_tween and _beat_tween.is_valid(): _beat_tween.kill() _beat_tween = null - AudioManager.clear_dip() + audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager # -- Log rendering -- @@ -519,7 +543,7 @@ func _format_entry(entry: Dictionary, alpha: float) -> String: ## Escape BBCode bracket characters in server-sourced text (Hoshe #2). static func _escape_bbcode(text: String) -> String: - return text.replace("[", "[lb]") + return text.replace("[", "[lb]").replace("]", "[rb]") ## Get a stable color for a character name, with contrast floor enforcement. @@ -575,19 +599,22 @@ func _expire_entries() -> void: var removed := false var has_fading := false - # Remove expired non-pinned entries from the front (oldest first) - while _log_entries.size() > 0: - var entry: Dictionary = _log_entries[0] + # Remove expired non-pinned entries (oldest first, skipping pinned) + var i := 0 + while i < _log_entries.size(): + var entry: Dictionary = _log_entries[i] if entry.pinned: - break # Pinned entries never expire + i += 1 + continue # Pinned entries never expire — keep scanning var age: int = now - entry.timestamp_msec if age < total_lifetime_msec: # Check if this entry is in the fading phase if age > int(_entry_lifetime * 1000.0): has_fading = true break - _log_entries.remove_at(0) + _log_entries.remove_at(i) removed = true + # Don't increment i — element at i is now the next entry # Check remaining entries for fading state if not has_fading: @@ -623,11 +650,18 @@ func _clear_options() -> void: # Hover callbacks -static func _make_hover_on(label: Control) -> Callable: +static func _make_hover_on(ctrl: Control) -> Callable: return func(): - label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER) + # RichTextLabel uses "default_color"; Label uses "font_color" + if ctrl is RichTextLabel: + ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER) + else: + ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER) -static func _make_hover_off(label: Control) -> Callable: +static func _make_hover_off(ctrl: Control) -> Callable: return func(): - label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) + if ctrl is RichTextLabel: + ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT) + else: + ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) diff --git a/client/ui/examine_display.gd b/client/ui/examine_display.gd new file mode 100644 index 000000000..3606e20e7 --- /dev/null +++ b/client/ui/examine_display.gd @@ -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 diff --git a/client/ui/examine_display.tscn b/client/ui/examine_display.tscn new file mode 100644 index 000000000..6cc2b9f5d --- /dev/null +++ b/client/ui/examine_display.tscn @@ -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) diff --git a/client/ui/journal_panel.gd b/client/ui/journal_panel.gd new file mode 100644 index 000000000..52255846b --- /dev/null +++ b/client/ui/journal_panel.gd @@ -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() diff --git a/client/ui/journal_panel.tscn b/client/ui/journal_panel.tscn new file mode 100644 index 000000000..9eedb4da9 --- /dev/null +++ b/client/ui/journal_panel.tscn @@ -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 diff --git a/client/ui/loading_screen.gd b/client/ui/loading_screen.gd new file mode 100644 index 000000000..c254e210b --- /dev/null +++ b/client/ui/loading_screen.gd @@ -0,0 +1,44 @@ +extends Control +## #257: Loading screen overlay — blocks input during save/load round-trip. +## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure). +## Full-screen, dark overlay with centered status text. + +const BG_COLOR := Color(0.0, 0.0, 0.0, 0.75) +const TEXT_COLOR := Color("#c8d0e0") +const FONT_SIZE := 18 + +var _label: Label = null + + +func _ready() -> void: + visible = false + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _build_ui() + + +func _build_ui() -> void: + var bg := ColorRect.new() + bg.color = BG_COLOR + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(bg) + + _label = Label.new() + _label.text = UIStrings.get_text("notifications.loading") + _label.add_theme_font_size_override("font_size", FONT_SIZE) + _label.add_theme_color_override("font_color", TEXT_COLOR) + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _label.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_label) + + +func show_loading() -> void: + visible = true + + +## Hide the loading overlay. success=false is reserved for future failure-state UI. +func hide_loading(success: bool = true) -> void: + visible = false diff --git a/client/ui/loading_screen.gd.uid b/client/ui/loading_screen.gd.uid new file mode 100644 index 000000000..29248a1a3 --- /dev/null +++ b/client/ui/loading_screen.gd.uid @@ -0,0 +1 @@ +uid://c6wtn7qk3mv2x diff --git a/client/ui/loading_screen.tscn b/client/ui/loading_screen.tscn new file mode 100644 index 000000000..388e82640 --- /dev/null +++ b/client/ui/loading_screen.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=2 format=3 uid="uid://b7rv9mkl4qpw3"] + +[ext_resource type="Script" path="res://ui/loading_screen.gd" id="1_loading"] + +; #257: Loading screen — full-screen overlay shown during save/load round-trip. +; Blocks input; dismissed when save_result arrives from server. +[node name="LoadingScreen" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_loading") diff --git a/client/ui/main_menu.gd b/client/ui/main_menu.gd new file mode 100644 index 000000000..33405255d --- /dev/null +++ b/client/ui/main_menu.gd @@ -0,0 +1,130 @@ +extends Control +## #258: Main menu — New Game / Continue / Load Game / Quit. +## New Game: generates per-game save directory (D-085), starts game. +## Continue: loads most recent save directory. +## Load Game: shows sorted save list for manual selection (#257). + +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 _load_game_btn: Button = $VBox/LoadGameBtn +@onready var _quit_btn: Button = $VBox/QuitBtn +@onready var _load_panel: Control = $LoadGamePanel +@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList +@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn + + +func _ready() -> void: + _new_game_btn.pressed.connect(_on_new_game) + _continue_btn.pressed.connect(_on_continue) + _load_game_btn.pressed.connect(_on_load_game_browse) + _quit_btn.pressed.connect(_on_quit) + _load_back_btn.pressed.connect(_on_load_back) + _load_panel.visible = false + _refresh_continue_state() + + +func _refresh_continue_state() -> void: + var saves := SessionManager.list_game_dirs() + _continue_btn.disabled = saves.is_empty() + _load_game_btn.disabled = saves.is_empty() + + +func _on_new_game() -> void: + GameState.pending_load_path = "" # clear stale load path from previous Load selection + 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: + GameState.pending_load_path = "" # clear stale load path from previous Load selection + 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) + + +var _list_built: bool = false # guard against queue_free() race on rapid reopen + + +func _on_load_game_browse() -> void: + if not _list_built: + _build_saves_list() + _list_built = true + _load_panel.visible = true + + +func _on_load_back() -> void: + _load_panel.visible = false + _list_built = false # allow rebuild on next open + + +func _on_quit() -> void: + get_tree().quit() + + +## Build the saves list panel from existing save directories, sorted by date (newest first). +func _build_saves_list() -> void: + for child in _saves_list.get_children(): + child.queue_free() + + var saves := SessionManager.list_game_dirs() + if saves.is_empty(): + var lbl := Label.new() + lbl.text = UIStrings.get_text("menu.load_game_empty") + lbl.add_theme_color_override("font_color", Color(BTN_DISABLED_COLOR)) + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _saves_list.add_child(lbl) + return + + for save in saves: + var btn := Button.new() + btn.text = _format_save_entry(save) + btn.add_theme_font_size_override("font_size", FONT_SIZE_BTN) + var has_save_file: bool = not save.get("newest_save", "").is_empty() + if has_save_file: + btn.add_theme_color_override("font_color", BTN_NORMAL_COLOR) + btn.pressed.connect(_on_save_selected.bind(save)) + else: + btn.add_theme_color_override("font_color", BTN_DISABLED_COLOR) + btn.disabled = true + _saves_list.add_child(btn) + + +func _on_save_selected(save: Dictionary) -> void: + var game_id: String = save.get("game_id", "") + var save_file: String = save.get("newest_save", "") + if save_file.is_empty(): + push_error("MainMenu: save entry '%s' has no newest_save — load cancelled" % game_id) + return + SessionManager.resume_game(game_id) + GameState.pending_load_path = "user://saves/" + game_id + "/" + save_file + get_tree().change_scene_to_file(GAME_SCENE) + + +## Format a save entry for display. game_id format: YYYYMMDD-HHMMSS-hex6. +static func _format_save_entry(save: Dictionary) -> String: + var game_id: String = save.get("game_id", "") + var parts := game_id.split("-") + if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6: + var d := parts[0] + var t := parts[1] + return "%s-%s-%s %s:%s:%s" % [ + d.substr(0, 4), d.substr(4, 2), d.substr(6, 2), + t.substr(0, 2), t.substr(2, 2), t.substr(4, 2), + ] + return game_id diff --git a/client/ui/minimap.gd b/client/ui/minimap.gd index 327471267..0f27ee575 100644 --- a/client/ui/minimap.gd +++ b/client/ui/minimap.gd @@ -1,15 +1,172 @@ +class_name MinimapRenderer extends Control -# Minimap — small viewport showing local area -# Placeholder implementation for now +## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6. +## +## Player always centered. Fixed-north (no rotation per D-015). +## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position. +## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI. +## Frame renders always — the insert is on even when no POIs are discovered. +## +## POI categories → shapes: +## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red) +## evidence / note / clue → square (ENTITY_COLOR_POI, amber) +## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal) +## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue) +## (default) → circle (INSERT_COLOR_TEXT) -@onready var viewport_container: SubViewportContainer = $SubViewportContainer +## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows. +const MINIMAP_RADIUS: float = 24.0 + +# Visual parameters +const FRAME_WIDTH: float = 1.2 +const PLAYER_DOT_RADIUS: float = 3.5 +const POI_DOT_RADIUS: float = 3.0 +const ARROW_HALF: float = 4.5 +const ARROW_LEN: float = 7.0 +const NORTH_TICK_LEN: float = 8.0 +const CARDINAL_TICK_LEN: float = 4.0 + +# Colors — insert palette from Constants, tuned for the circular minimap frame +const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82) +const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha +const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick +const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks +const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER + +var _insert_active: bool = true func _ready() -> void: - print("Minimap: Initialized") + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_process(true) -# Update minimap view (stub) -func update_minimap(player_pos: Vector2, entities: Array) -> void: - # TODO: Render minimap view - # This will show a top-down view of the local area - pass +func _process(_delta: float) -> void: + if _insert_active: + queue_redraw() + +## Called from main.gd when GameState.insert_active changes. +## Hides the minimap overlay when the neural insert is inactive. +func set_insert_active(active: bool) -> void: + _insert_active = active + visible = active + +func _draw() -> void: + var sz: Vector2 = get_rect().size + var center := sz / 2.0 + # Outer radius: fill control with 1px edge padding + var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0 + + # --- Background fill --- + draw_circle(center, outer_r, COLOR_BG) + + # --- Frame ring --- + draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true) + + # --- Cardinal ticks (N brighter, E/S/W dimmer) --- + _draw_cardinal_ticks(center, outer_r) + + # --- Player dot at center --- + draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER) + # Soft bloom ring + draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32, + Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true) + + # --- POIs --- + var pois: Array = GameState.discovered_pois + if pois.is_empty(): + return + + var px: float = GameState.player_position.x + var py: float = GameState.player_position.y + # Pixels per sim tile within the inner drawable area + var inner_r: float = outer_r - FRAME_WIDTH + var scale: float = inner_r / MINIMAP_RADIUS + + for poi in pois: + if not poi is Dictionary: + continue + if not poi.has("x") or not poi.has("y"): + continue + + var dx: float = float(poi.x) - px + var dy: float = float(poi.y) - py + var dist: float = sqrt(dx * dx + dy * dy) + var category: String = poi.get("poi_category", "") + var color: Color = _category_color(category) + + if dist < 0.01: + # POI at exact player position — draw at center offset slightly + _draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category) + elif dist <= MINIMAP_RADIUS: + # Nearby: project to screen position within circle + var poi_screen := center + Vector2(dx, dy) * scale + # Hard-clamp to inner circle boundary (guards floating-point edge cases) + var rel := poi_screen - center + if rel.length() > inner_r - POI_DOT_RADIUS - 1.0: + poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0) + _draw_poi_shape(poi_screen, color, category) + else: + # Distant: arrow at border pointing toward POI direction + var dir := Vector2(dx, dy).normalized() + var arrow_tip := center + dir * (inner_r - 2.0) + _draw_border_arrow(arrow_tip, dir, color) + + +func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void: + # North tick — longer, brighter, the fixed-north indicator + var n_dir := Vector2(0.0, -1.0) + draw_line( + center + n_dir * (outer_r - NORTH_TICK_LEN), + center + n_dir * outer_r, + COLOR_NORTH, FRAME_WIDTH + 0.5, true + ) + # East (PI/2), South (PI), West (3PI/2) — shorter, dimmer + for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]: + var dir := Vector2(cos(angle), sin(angle)) + draw_line( + center + dir * (outer_r - CARDINAL_TICK_LEN), + center + dir * outer_r, + COLOR_CARDINAL, FRAME_WIDTH, true + ) + + +func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void: + match category.to_lower(): + "danger", "threat", "hostile": + # Diamond for danger + var s: float = POI_DOT_RADIUS + 1.0 + draw_polygon( + PackedVector2Array([ + pos + Vector2(0.0, -s), pos + Vector2(s, 0.0), + pos + Vector2(0.0, s), pos + Vector2(-s, 0.0) + ]), + PackedColorArray([color, color, color, color]) + ) + "evidence", "note", "clue": + # Square for evidence/clue + var s: float = POI_DOT_RADIUS - 0.5 + draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color) + _: + draw_circle(pos, POI_DOT_RADIUS, color) + + +## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip. +func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void: + var perp := Vector2(-dir.y, dir.x) + var base_center := tip - dir * ARROW_LEN + draw_polygon( + PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]), + PackedColorArray([color, color, color]) + ) + + +func _category_color(category: String) -> Color: + match category.to_lower(): + "danger", "threat", "hostile": + return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red + "evidence", "note", "clue": + return Constants.ENTITY_COLOR_POI # #e8c547 — amber + "contact", "npc", "person": + return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal + _: + return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue diff --git a/client/ui/minimap.tscn b/client/ui/minimap.tscn index 9ce330777..8e34b766b 100644 --- a/client/ui/minimap.tscn +++ b/client/ui/minimap.tscn @@ -2,33 +2,21 @@ [ext_resource type="Script" path="res://ui/minimap.gd" id="1_minimap"] -[node name="Minimap" type="Control"] +; MinimapRenderer — diegetic neural insert overlay (D-013, D-049 z-layer 6). +; 160x160px circle, top-right corner with 16px margin. +; Rendered via _draw() — no SubViewport needed. +; Positioned in InsertOverlay (CanvasLayer 10) by main.tscn. + +[node name="MinimapRenderer" type="Control"] +custom_minimum_size = Vector2(160, 160) layout_mode = 3 anchors_preset = 1 anchor_left = 1.0 anchor_right = 1.0 -offset_left = -200.0 -offset_bottom = 200.0 +offset_left = -176.0 +offset_top = 16.0 +offset_right = -16.0 +offset_bottom = 176.0 grow_horizontal = 0 mouse_filter = 2 script = ExtResource("1_minimap") - -[node name="SubViewportContainer" type="SubViewportContainer" parent="."] -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 - -[node name="SubViewport" type="SubViewport" parent="SubViewportContainer"] -size = Vector2i(200, 200) -render_target_update_mode = 4 - -[node name="Background" type="ColorRect" parent="SubViewportContainer/SubViewport"] -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 -grow_horizontal = 2 -grow_vertical = 2 -color = Color(0.1, 0.1, 0.1, 0.7) diff --git a/client/ui/monologue_display.gd b/client/ui/monologue_display.gd index f95b913a3..37a52844d 100644 --- a/client/ui/monologue_display.gd +++ b/client/ui/monologue_display.gd @@ -35,6 +35,8 @@ const _LATTICE_COLORS: Dictionary = { } const _FALLBACK_STANDARD: Color = Color("#c8d0e0") const _FALLBACK_URGENT: Color = Color("#e0e8f8") +const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification +const _NOTIFICATION_DURATION: float = 2.5 @onready var _vbox: VBoxContainer = $VBoxContainer @@ -62,7 +64,10 @@ func _process(delta: float) -> void: var now := float(Time.get_ticks_msec()) if now >= _next_fade_in_msec: var next: Dictionary = _queue.pop_front() - _show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile) + if next.get("is_notification", false): + _show_notification_line(next.text) + else: + _show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile) # Display a monologue line. @@ -71,6 +76,26 @@ func _process(delta: float) -> void: # Empty text is silently ignored — no slot created, no queue entry. # lattice_profile is read from GameState here and passed down — renderer stays # decoupled from the autoload (D-020 renderer contract). +# #554: Show a brief system notification (save/load result, connection status). +# Uses neutral color, short duration, bypasses lattice_profile styling. +func show_notification(text: String) -> void: + if text.is_empty(): + return + var now := float(Time.get_ticks_msec()) + if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec: + _show_notification_line(text) + else: + var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true} + if _queue.size() < MAX_QUEUE: + _queue.append(entry) + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + else: + var lowest := _lowest_priority_idx() + if 1 >= _queue[lowest].priority: + _queue[lowest] = entry + _queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) + + func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void: if text.is_empty(): return @@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: # Internal # --------------------------------------------------------------------------- +func _show_notification_line(text: String) -> void: + var container := MarginContainer.new() + container.add_theme_constant_override("margin_left", 4) + container.add_theme_constant_override("margin_right", 4) + container.add_theme_constant_override("margin_top", 2) + container.add_theme_constant_override("margin_bottom", 2) + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.fit_content = true + label.scroll_active = false + label.add_theme_font_size_override("normal_font_size", 13) + var safe_text := text.replace("[", "[lb]") + label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text] + container.add_child(label) + _vbox.add_child(container) + var slot := { + node = container, + expire_timer = _NOTIFICATION_DURATION, + priority = 1, + tween = null, + } + _visible.append(slot) + _next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0 + container.modulate.a = 0.0 + var tween := create_tween() + slot.tween = tween + tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC) + + func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void: var line_node := _build_line_node(text, is_urgent, lattice_profile) _vbox.add_child(line_node) diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index 588e9b4eb..cbde721e3 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -121,6 +121,14 @@ func _build_ui() -> void: close_btn.pressed.connect(close) _container.add_child(close_btn) + # Quit to Menu button (#258: D-085 session management) + var quit_btn := Button.new() + quit_btn.text = UIStrings.get_text("menu.quit_to_menu") + quit_btn.add_theme_font_size_override("font_size", FONT_SIZE) + quit_btn.add_theme_color_override("font_color", Color("#c87040")) + quit_btn.pressed.connect(_on_quit_to_menu) + _container.add_child(quit_btn) + func _destroy_ui() -> void: if _container: @@ -153,6 +161,11 @@ func _draw() -> void: HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR) +func _on_quit_to_menu() -> void: + close() + SessionManager.quit_to_menu() + + static func _format_db(db: float) -> String: if db <= -40.0: return "mute" diff --git a/content/global/knowledge/ring.yaml b/content/global/knowledge/ring.yaml new file mode 100644 index 000000000..fc084e20f --- /dev/null +++ b/content/global/knowledge/ring.yaml @@ -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. diff --git a/content/modules/tier1/smuggling_ring_v0_1.yaml b/content/modules/tier1/smuggling_ring_v0_1.yaml new file mode 100644 index 000000000..9e968575c --- /dev/null +++ b/content/modules/tier1/smuggling_ring_v0_1.yaml @@ -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." diff --git a/content/schemas/drama_module.schema.yaml b/content/schemas/drama_module.schema.yaml new file mode 100644 index 000000000..02fb039d5 --- /dev/null +++ b/content/schemas/drama_module.schema.yaml @@ -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 diff --git a/db/connectors b/db/connectors new file mode 120000 index 000000000..d424fdf7f --- /dev/null +++ b/db/connectors @@ -0,0 +1 @@ +../tooling/db \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index d202f85d2..249f19012 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,5 +1,5 @@ -- Commonwealth Project Ticketing Database Schema --- Access via: python3 db/connectors/sqlite_connector.py <command> +-- Access via: python3 tooling/db/sqlite_connector.py <command> -- DO NOT use sqlite3 CLI (crashes in Claude Code due to std::bad_alloc bug) PRAGMA journal_mode=WAL; @@ -64,7 +64,7 @@ CREATE INDEX IF NOT EXISTS idx_history_ticket ON ticket_history(ticket_id); -- --------------------------------------------------------------------------- -- Decision Sync Tables --- Populated by: python3 db/connectors/decisions_sync.py +-- Populated by: python3 tooling/db/decisions_sync.py -- Source: decisions/*.md domain files -- --------------------------------------------------------------------------- diff --git a/decisions/README.md b/decisions/README.md index 0b8d59f11..d589e5eec 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi | File | Domain | Decisions | |------|--------|-----------| -| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 | -| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-084 | -| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074 | -| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065 | +| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109 | +| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 | +| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107 | +| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091 | | [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 | -| [questions.md](questions.md) | Open questions | Q-001 through Q-026 | +| [questions.md](questions.md) | Open questions | Q-001 through Q-050 | | [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 | ## Querying Decisions @@ -24,7 +24,7 @@ The SQLite database contains a `decisions` table synced from these files. Common ```bash # All active architecture decisions -db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE domain='architecture' AND status='active'" +tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE domain='architecture' AND status='active'" # Decisions without implementing tickets make decisions-orphan diff --git a/decisions/architecture.md b/decisions/architecture.md index 99add8d4a..eb9040693 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -210,4 +210,130 @@ 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) + +--- + +### D-096: DistrictLayoutMode — Grid and Organic Support +- **Date:** 2026-02-27 +- **Decision:** Two layout modes coexist for district generation. `Grid`: Commission-planned districts with rectilinear block placement. `Organic`: pioneer/growth districts with block offsets (±16 sim tiles per axis), rotation (0–3 steps, 15° increments), variable street width (0.75–2.0×). Hard technical ceiling: maximum rotation ±45°. Beyond 45°, tile-based pathfinding produces unacceptable movement artifacts. Organic districts produce curved-street impressions through angular jogs and irregular setbacks. Grid vs. Organic proportions must vary per seed to prevent predictable meta-level patterns. +- **Rationale:** Grid = power imposed (Commission-planned). Organic = power negotiated (pioneer settlements, organic growth). Both modes encode political and settlement history in spatial form. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. Full spec: `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-1. +- **Raised by:** Tyre (technical architecture), Miri (cultural grammar). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-094 (spatial hierarchy), Q-036 (district generator) + +### D-097: Guarantee Tier System — Universal / Full-Only / Conditional +- **Date:** 2026-02-27 +- **Decision:** The district generator runs a `GuaranteeAuditResult` with three tiers of spatial guarantees. **Tier 1 — Universal (all inhabited):** Social Hub, Informal Zone, Encounter Corridor. **Tier 2 — Full-complexity:** Traffic Chokepoint, Institutional Space, Insider Space, Economic Node, Horizon View Corridor (coastal), BreachOnly Zone (≥1), Rooftop Discovery Zone (tall structures). **Tier 3 — Conditional:** A-1 Elevated Vantage, A-2 Egress Multiplicity, A-3 Temporal Opacity Window, A-4 Non-Institutional Route, Economic Asymmetry Signal, Power Gradient Visibility. A Full-complexity coastal urban hub gets up to 13 checks. Archetype placement must vary in angular position (not just distance) across seeds — audit fails if archetypes cluster predictably across a test batch of N seeds. +- **Rationale:** The generator makes contracts it keeps. Guaranteed affordances ensure every playstyle has spatial affordances in any district, without hand-crafting each location. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. Full spec: `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-2. +- **Raised by:** Gestalt (tier structure + assassin lens integration), Tyre (GuaranteeAuditResult struct). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-103 (assassin lens guarantees A-1 through A-4), D-102 (horizon view corridor — Tier 2 coastal) + +### D-099: WallBackside / TileBehindState — Dual Classification +- **Date:** 2026-02-27 +- **Decision:** Two complementary enums classify tiles behind wall surfaces. `WallBackside` (structural): what is physically there — `AdjacentSpace | StructuralFill | ServiceVoid | ChunkBoundary | Exterior`. `TileBehindState` (gameplay): what kind of space this represents — `StructuralFill | HiddenRoom | Interstitial`. Mapping: `ServiceVoid → Interstitial`; `AdjacentSpace → HiddenRoom or StructuralFill` depending on access tier. Era-tagged infrastructure cavity contents with standardized color codes: Era 1 power conduit only (`#c8b840`), Era 2 power + water/coolant (`#4888c8`) + comm lines (`#b8b8b8`), Era 3 full bundle. Backside assignments within a template must have seed-driven variation — not fixed template values. +- **Rationale:** "Every wall is a secret keeper." No tile is ever void. Dual classification separates structural truth (what's there physically) from gameplay meaning (what does this imply for the player's investigation). +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-4. +- **Raised by:** Tyre (WallBackside), Gestalt (TileBehindState). Full team sign-off. +- **Dissent:** None. + +### D-100: Dynamic Modification via Overlay — DamageOverlay and RegenerationStrategy +- **Date:** 2026-02-27 +- **Decision:** Generator output is immutable after Phase 1. All post-generation modifications are applied via overlay, not re-generation. `DamageOverlay` struct: `overlay_type` (GasExplosion | Fire | Structural { collapse_direction } | Flooding), `epicenter: ChunkLocalPos`, `radius: f32`, `intensity: f32`, `scatter_seed: u64` (variation within zone only). `RegenerationStrategy` enum: `LocalOverlay(DamageParameters)` for in-playthrough events (MANDATORY), `SoftReseed { seed_modifier: u64 }` at scenario boundaries only, `FullReseed` at era-level discontinuities only. Trauma event → visual stage mapping: PhysicalDestruction/ViolenceEvent → Stage 2 (Fresh Aftermath), decays to Stage 3; EconomicDisruption/PoliticalShock/MigrationShock → quarter fill modifier. Full stage sequence: Stage 1 Active → Stage 2 Fresh Aftermath → Stage 3 Stabilized → Stage 4 Reconstruction → Stage 5 Healed Scar. Destruction palette is corruption-only: no new colors introduced by destruction. Single exception: `#c8d8f0` open-sky tile appears when a roofed structure has its roof removed. See D-109 for the XOR prohibition as architectural mandate. +- **Rationale:** Modification history diverges per playthrough on the same seed. Same world, different event histories, different delta layers — this is the replayability engine. Causal legibility requires the player to be able to read what happened from the world state. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-5. +- **Raised by:** Tyre (structs), Gestalt (LocalOverlay mandate). Destruction stages and palette constraint: Araminta (Round 5). +- **Dissent:** None. +- **Cross-reference:** D-109 (XOR prohibition as architectural mandate), D-107 (trauma events — cultural track) + +### D-101: ZonePalette Modifier System +- **Date:** 2026-02-27 +- **Decision:** Zone palettes use `ZonePalette { base: BasePalette, modifiers: Vec<PaletteModifier> }`. Eight canonical base terrain types: T1 temperate farmland (warm organic, natural lighting) / T2 industrial farmland (cool grey-green, artificial lighting) / T3 wilderness / T4 grassland / T5 coastal water (deep near-black blue, animated specular; referenced by D-102 horizon corridor guarantee) / T6 beach/coastal margin (warm dark tan) / T7 mountain/high terrain (dark blue-grey stone, snow at elevation) / T8 desert/arid. T1 and T2 are explicitly distinct farmland types. Additional terrain types must be specified with new numbers — not silent replacements for existing types. Modifier axes: A (heritage root → material character), B (economic tier → condition/density), C (era → material generation), plus faction overlay, climate, condition, season. Palette modifiers influence NPC appearance as well as environment (people dress like they're from here). +- **Rationale:** A zone's visual identity must be legible at a glance. Palette modifiers create cultural visual identity without rewriting base terrain. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-6. +- **Raised by:** Araminta (terrain types and color specs, canonical T5/T7 numbering corrected Round 5), Tyre (palette struct). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-102 (horizon view corridor — T5 coastal water is the referenced terrain type), D-104 (heritage grammar overlay — modifier axis A) + +### D-102: Horizon View Corridor as Coastal Guarantee +- **Date:** 2026-02-27 +- **Decision:** A **negative-space** reservation for coastal districts: ≥8 visual tiles unobstructed view corridor from nearest public street to water's edge. No building, tree, or z=4 element may occupy this corridor. A low z=2 element (railing, bench, bollard) marks the waterfront point as a designed viewing location. Tier 2 Conditional guarantee — applies to Full-complexity coastal districts. Position within the district must vary per seed; the Wow Moment of seeing the horizon must be discovered, not expected. +- **Rationale:** "Negative-space reservation" framing — the generator reserves space by prohibiting placement, not by placing something. The view of the horizon is a spatially guaranteed player experience. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-7. +- **Raised by:** Araminta (visual grammar and negative-space framing), Tyre (implementation constraint). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-097 (guarantee tier system — Tier 2), D-101 (ZonePalette — T5 coastal water is the terrain type this guarantee references) + +### D-103: Assassin Lens Spatial Guarantees — A-1 through A-4 +- **Date:** 2026-02-27 +- **Decision:** Four derived spatial properties validated by the guarantee audit for Full-complexity districts. These are **derived properties of existing spatial configuration**, not assassin-tagged features — they add no generation cost; the audit validates existing output. **A-1 Elevated Vantage** (Tier 3): ≥1 position with clear LOS cone to Traffic Chokepoint. **A-2 Egress Multiplicity** (Tier 3): ≥2 exit routes to adjacent districts. **A-3 Temporal Opacity Window** (Tier 3): ≥1 time window where Social Hub has reduced ambient NPC coverage. **A-4 Non-Institutional Route** (mandatory Full-complexity): ≥1 route to any Insider zone not passing through high-security institutional spaces. A-1/A-2/A-3 are Tier 3 Conditional (trigger on `complexity_tier == Full`). A-4 is mandatory for all Full-complexity districts regardless of playstyle. +- **Rationale:** The investigator/assassin playstyle needs guaranteed affordances without the generator explicitly building for assassination. Derived properties keep generation cost zero while ensuring spatial conditions exist. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-8. +- **Raised by:** Gestalt (assassin lens framing and derived-properties insight). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-097 (guarantee tier system — Tier 3) + +### D-106: Vertical Scale Architecture and Rooftop Bar Clause +- **Date:** 2026-02-27 +- **Decision:** Four height tiers: S1 (1–2 z-levels, surface + roof/mezzanine), S2 (3–10), S3 (11–30), S4 (30+). Shadow length is the primary height signal in top-down view (2–40 visual tiles). Lazy z-level loading: `ZLevelLoadState: Loaded | Skeleton | Ungenerated` — only current + adjacent z-levels filled by Phase 2. **Rooftop Bar Clause:** Every tall structure (z_band_count ≥ 3) must assign `RooftopConfig: Restricted | PublicWithHiddenLayer`. Discovery layer mandatory in both configurations. Heritage root **weights the probability** between the two configs — it does not determine the outcome. Final config is seeded per-building; a minority of buildings of any heritage root may be the non-dominant type (a Frost building with a rooftop bar must be possible). Z-band floor boundaries must have seed-variation within cultural ordering constraints. Vertical access routes are playthrough-history dependent. +- **Rationale:** Height has meaning — floor 30 has information floor 1 cannot have because it is harder to reach. Full determination of rooftop config by heritage root kills the discovery moment. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-11. +- **Raised by:** Tyre (z-level architecture), Ozzie (Rooftop Bar Clause — discovery guarantee). Ozzie + Araminta corrected "determines" → "weights probability" in Round 5. +- **Dissent:** None. +- **Cross-reference:** D-094 (spatial hierarchy), D-097 (guarantee tier system — Rooftop Discovery Zone is Tier 2) + +### D-108: MobileChunk Specification +- **Date:** 2026-02-27 +- **Decision:** Entity-carried interior space attached to a mobile world entity. Not a district — uses the same chunk fill primitives in a simpler flat structure (no Phase 1/Phase 2 split, no block grid, no zone negotiation). Key structs: `MobileChunk`, `MobileInterior`, `VesselClass`, `MobileMovementState` (Docked / InTransit / InterSystem / Idle), `TransitSocialModifier`, `MobileNpcSlot`, `NpcPersistence` (Crew | Passenger). `Idle` = vessel parked at a location but not docked to infrastructure (anchored ship, grounded shuttle). Vessels are **persistent world entities** — interior cache keyed by entity_id persists across voyages for crew state. `Docked` state requires `dock_position`, `connected_chunk: Option<ChunkCoord>`, `docked_since: SimTick`, `scheduled_departure: Option<SimTick>`. `scheduled_departure` must be populated by the generator; vessels without departure schedules are an error state. Cultural grammar: `TransitSocialModifier` with `TransitVariant` (BoundedLinear | BoundedMobile | InterSystem). Vessel visual grammar (5 rules): (1) hull uses vessel-identity material, not zone palette; (2) windows reveal exterior context (docked vs. transit); (3) compression modifier tightens proportions; (4) section transitions use vessel-identity threshold elements; (5) class stratification via proportion, not palette. Replayability requirements R-V-1 through R-V-6 in `docs/workshops/generator-architecture/round-4-notes.md` §5. Memory: ~0.5–4KB metadata + up to 64KB ChunkData per vessel; paged by streaming model. +- **Rationale:** "The journey is content — mobile environments are social pressure cookers, not loading screens with chairs." Vessel persistence and crew state continuity make the world feel real. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-13. +- **Raised by:** Tyre (struct design), Miri (cultural grammar — `miri-round4.md`), Nigel (replayability requirements), Ozzie (player experience). Visual grammar: Araminta (`araminta-round4.md` §2). +- **Dissent:** Nigel initially proposed instanced districts for vessels; lead ruled entity-carried MobileChunk for persistence. +- **Cross-reference:** D-100 (DamageOverlay applies to vessel damage), D-109 (LocalOverlay mandate), Q-046 (departure schedule — resolved by this D-record) + +### D-109: DamageOverlay / RegenerationStrategy Prohibition — Architectural Mandate +- **Date:** 2026-02-27 +- **Decision:** XOR reseeding for in-playthrough events is **architecturally prohibited**. `LocalOverlay` is the mandatory modification strategy for all events that occur while the player is present. `SoftReseed` and `FullReseed` are permitted only at scenario-boundary and era-level discontinuities respectively — events the player was not present for, where causal legibility is not required. This prohibition is filed as a separate D-record from D-100 because it establishes the modification principle for the entire game, not just the overlay mechanics. +- **Rationale:** Causal legibility: the player must be able to look at a damaged district and understand what happened. XOR reseeding destroys the causal thread. Unanimous consensus across all workshop participants — the strongest architectural agreement of the entire workshop. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-14. +- **Raised by:** Gestalt (XOR prohibition framing), Tyre (RegenerationStrategy struct). Unanimous. +- **Dissent:** None. +- **Cross-reference:** D-100 (DamageOverlay + RegenerationStrategy full specification) + +--- + +*29 decisions. Last updated: 2026-02-27 (D-096 through D-109 added — Generator Architecture Workshop #562)* diff --git a/decisions/content.md b/decisions/content.md index 1870407a0..c89c40b96 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -182,6 +182,109 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio - **Raised by:** Tyre (technical analysis, architecture synthesis) - **Dissent:** Gestalt endorses D-075 (reviewed 2026-02-19). The emergent archetype distinction is sufficient: access tier tags already encode "authority figure lines" vs "insider lines" in content; starting knowledge differentials produce different dialogue gate timings per character; adding an archetype filter would create per-character content maintenance burden and dilute the "two keyholes on the same world" experience (D-027). Knowledge vocabulary doc (#368) confirms this works in practice — same fact IDs, different starting confidence levels, different gate-open timing per character. *Nigel's input still pending.* +### D-084: Dual-namespace line ID scheme — role pool + instance override +- **Date:** 2026-02-25 +- **Decision:** Line IDs for auto-generated NPC content use a dual-namespace approach that eliminates the Q-028 collision problem without altering the existing ID format. + - **Role namespace (unchanged, primary):** `{role-slug}_{d|m|e}_{###}` — e.g., `dock-worker_d_001`. These are shared lines delivered by any instance of the role. One file, one ID sequence per role-at-location. No per-instance authoring, no collision possible. All existing authored content is unaffected. + - **Instance namespace (new, opt-in):** `{role-slug}-{zero-padded counter}_{d|m|e}_{###}` — e.g., `dock-worker-07_d_001`. Counter is the generation-order rank within the role group for that district, starting at 01, assigned deterministically from the world seed. Used only when a specific generated NPC needs authored content that differs from the role pool. +- **Key design choices:** + - **Why the collision problem is mostly already solved:** The Q-028 collision framing assumed NPC-scoped IDs require per-instance ID sequences. They don't. D-028 tagged line pools are role-scoped: `dock-worker_d_001` is content that any dock worker can deliver. Forty dock workers all drawing from `dock-worker_d_###` is correct behavior, not a collision. A collision would only occur if two *distinct authored lines* shared the same ID — which the role namespace prevents by definition (one file, one sequence). + - **Instance namespace scope:** Opt-in only. Tier 3 (flat wallpaper) and Tier 2 (mundane triangles) auto-generated NPCs use the role pool exclusively. Instance pools are authored only when a specific generated NPC needs content variation the role pool cannot supply (e.g., a generated NPC flagged as a triangle member with unique tell lines). + - **History log disambiguation:** The speaker of a line is identified by `StableId`, not by line ID. Line ID identifies content; `StableId` identifies the speaker. `(StableId: 42, line_id: "dock-worker_d_001")` and `(StableId: 43, line_id: "dock-worker_d_001")` are two different log entries for the same content line — no collision in the log. + - **Counter stability:** Generation order within a role group is seeded from the world seed. Same seed → same order → same counter assignments. Counter is recorded in the district's NPC roster at world-gen time. The counter survives save/load because it is part of the generated NPC's profile, not recomputed at runtime. + - **Schema compatibility:** The existing ID regex `^[a-z][a-z0-9-]*_[dme]_\d{3}$` already accepts `dock-worker-07_d_001`. No regex change required. No content migration required. +- **Rejected alternatives:** + - **StableId prefix (`npc-00042_d_001`):** StableId is assigned at load time from sorted canonical IDs. Authors cannot know it before writing files. A generate-then-bake pipeline would break the content/generation separation principle. Rejected. + - **UUID suffix (`dock-worker-a3f2_d_001`):** UUIDs are stable per seed but change across seeds, orphaning any authored instance content on replay. Rejected. + - **Slug registry with collision resolution (`dock-worker`, `dock-worker-2`, ...):** First instance gets a privileged non-suffixed slug while all others get a counter, creating asymmetry with no upside. Rejected. +- **Implementation requirement:** The content registry (`server/src/knowledge/registry.rs` or a new `server/src/content/npc_slug.rs`) tracks a `RoleCounter: BTreeMap<String, u32>` per district. Incremented when a generated NPC claims an instance namespace slot. Stored in the district manifest. Provides `generate_instance_slug(role_slug) -> String` returning `{role-slug}-{counter:02}`. +- **Hand-authored NPCs:** Unchanged. `kael-davan`, `sera-venn`, and all named authored NPCs keep their current slugs and ID sequences. No migration. +- **Resolves:** Q-028 +- **Cross-reference:** Line ID scheme ([D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools)), population model ([D-029](#d-029-population-entanglement-ratio--305020)), NPC generation ([D-024](#d-024-npc-generation-model--10-axes--combat-component)) +- **Raised by:** Gestalt (Sprint 18, #544). Endorsed by Tyre pending implementation review. +- **Dissent:** None. + +### 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) + +--- + +### D-098: TrianglePurpose Enum +- **Date:** 2026-02-27 +- **Decision:** Social triangles carry `Vec<TrianglePurpose>` — a multi-tag set for playstyle accessibility. Enum values: `Investigation, Economic, Social, Political, Tactical, Mundane`. `Tactical` encodes the assassination contract in spatial form (target + protector + informant/witness). Multiple purpose tags per triangle: a smuggling operation can be `Economic + Tactical` simultaneously. Purpose tags ensure the right drama is surfaced to the player whose active lens matches — they add no generation cost, categorizing existing output. +- **Rationale:** Purpose tags are the bridge between the generator's social structure and the player's active playstyle. The generator doesn't build for one playstyle — it tags what's already there. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-3. +- **Raised by:** Gestalt (triangle purpose model). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-025 (social site / functional cluster — triangles populate social sites), D-097 (guarantee tier system — triangles feed the audit) + +### D-104: Heritage Grammar Overlay for Non-Urban Palettes +- **Date:** 2026-02-27 +- **Decision:** Data-driven `HeritageGrammarOverlay` structs (10 per heritage root). Loaded once at generator startup, applied at Phase 2 chunk fill by weighted blending. Blend rules: continuous fields (decorative_density, repair_visibility, etc.) use weighted average; categorical fields (boundary_character, open_space_character) use dominant heritage weight; object tag lists use union of preferred/accent tags and intersection-exclusion of excluded tags. Phase 1 exception: `gathering_probability` evaluated at block planning for quarter pre-assignment. Authoring domain separation: **Miri** authors organizational principles, boundary character, spacing, social grammar (HeritageGrammarOverlay Rust struct / authored data). **Araminta** authors visual expression — object sets, arrangement algorithms, floor surface variants, overhead flora density and character, wall/structure material character, boundary material type, lighting temperature (TOML modifier files, one per heritage root). Shared: `ObjectTag` vocabulary must be co-maintained (see Q-049). +- **Rationale:** Heritage roots must be spatially legible — the visual grammar of a Frost community versus a Tide community must be apparent to the observant player without a label. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-9. +- **Raised by:** Miri (cultural grammar spec), Araminta (TOML modifier design and authoring domain). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-101 (ZonePalette — heritage modifier axis A), D-105 (informal zone typology — heritage correlations), Q-049 (ObjectTag co-maintenance) + +### D-105: Non-Urban Informal Zone Typology +- **Date:** 2026-02-27 +- **Decision:** Informal zones (spaces outside the community's social field) are defined by the type of social permission governing them, not by institutional absence. Three types: `physical_distance` — sparse objects, unmaintained floor; isolation is the visual. `social_permission` — normal zone palette; gathering infrastructure present; cover is about convention, not geography. `utilitarian_cover` — functional work objects; space reads as work space; unofficial use invisible to casual observation. Heritage root correlations: Frost/Stone → `physical_distance`; Tide/Vine/Dust → `social_permission`; Iron/Salt → `utilitarian_cover`. (Dust = maximum communal observation, privacy is negotiated not physical; Iron = labor function covers presence.) Location within terrain seeded independently. Visual grammar per type: `docs/workshops/generator-architecture/araminta-round4.md`. +- **Rationale:** Privacy mechanics emerge from community culture. How you hide in a Frost community (physical distance) is architecturally different from how you hide in a Dust community (social agreement). The typology makes privacy mechanics culturally legible. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-10. +- **Raised by:** Miri (heritage root correlations, canonical mapping corrected Round 5), Araminta (visual grammar per type). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-104 (heritage grammar overlay) + +### D-107: Trauma Events as EraModification Subtypes +- **Date:** 2026-02-27 +- **Decision:** Trauma events are a subtype of `EraModification` with dual-track effects. Five subtypes: `PhysicalDestruction, EconomicDisruption, PoliticalShock, ViolenceEvent, MigrationShock`. Separate tracks: (1) structural damage via `StructuralChange` in ChunkMutations (applied as `LocalOverlay` per D-109); (2) cultural response via NPC weight distribution shift in `DistrictRuntimeState.npc_pattern_weights`. Decay rate seeded per-community with variation around heritage-root baseline (`trauma_visual_decay_rate: slow | medium | fast`, default medium). Design principle: **Trauma intensifies culture, it does not transform it.** A stressed community becomes a more concentrated version of itself — Frost communities close harder, Tide communities grief more publicly, Iron communities organize more collectively. Decay is toward the community's pre-trauma baseline, not toward a new equilibrium. Players who have learned a heritage root's trust model can predict community behavior in the aftermath. +- **Rationale:** Cultural response must be legible — and predictable to a player who has invested in understanding the heritage root. Trauma as amplifier (not transformer) rewards prior observation. +- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-12. +- **Raised by:** Miri (trauma subtypes and heritage decay model, "trauma intensifies culture" principle added Round 5), Tyre (struct design and decay architecture). Full team sign-off. +- **Dissent:** None. +- **Cross-reference:** D-100 (DamageOverlay — structural track), D-104 (heritage grammar — cultural baseline), D-109 (LocalOverlay mandate) + +--- + +*25 decisions. Last updated: 2026-02-27 (D-098, D-104, D-105, D-107 added — Generator Architecture Workshop #562)* diff --git a/decisions/perception.md b/decisions/perception.md index 8a0f253e5..aab16a5de 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -446,7 +446,7 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Implements:** Tickets #547 (struct + detection), #550 (monologue + event chain) - **Cross-reference:** [D-034](content.md#d-034-the-friend-npc-archetype), [D-033](perception.md#d-033-entity-color--relationship-to-player), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-079](#d-079-knowledge-grant-architecture), [D-080](#d-080-npc-to-npc-knowledge-propagation) -### D-084: Insert icon system — custom SVG, no icon font +### D-086: Insert icon system — custom SVG, no icon font - **Date:** 2026-02-24 - **Decision:** The insert overlay (z-layer 6) uses **custom SVG icons**, not an icon font. The v0.1 icon vocabulary (~12–15 shapes: stance indicators, perception mode icons, inventory item silhouettes, border arrows) is too small and too specific for a font. No available icon font (Material Symbols, Phosphor, Feather, Tabler) matches the insert's geometric/diegetic visual language — they all read as "app UI," not "neural lattice overlay." Custom SVGs authored to the insert's constraint set (clean geometry, minimal anchor points, `#c8d0e0` chrome color, 1px stroke base weight) ensure all insert elements feel generated by the same system. - **Why not an icon font:** @@ -467,4 +467,4 @@ How the player observes and interacts with the world: camera, fog, line-of-sight --- -*38 decisions. Last updated: 2026-02-24 (D-084: Insert icon system)* +*38 decisions. Last updated: 2026-02-24 (D-086: Insert icon system)* diff --git a/decisions/questions.md b/decisions/questions.md index d2dbf7d79..368f9609e 100644 --- a/decisions/questions.md +++ b/decisions/questions.md @@ -161,13 +161,173 @@ Tracked questions awaiting discussion or resolution. - **Source:** Sprint 10 PR review discussion (2026-02-19) ### Q-028: Collision-resistant line IDs for auto-generated NPCs -- **Status:** Open -- **Question:** The D-035 NPC-scoped line ID scheme uses NPC slugs as prefix (`kael-davan_d_001`). Hand-authored NPCs have unique slugs, but auto-generated populations (D-029: hundreds of NPCs) will produce collisions when the generator creates multiple NPCs with the same role slug (e.g., two `dock-worker` NPCs). What collision-resistance mechanism should be used? Options: (1) Short UUID/hash suffix on auto-gen slugs (`dock-worker-a7f3_d_001`), (2) Entity UUID as prefix, (3) Slug registry that guarantees uniqueness at generation time, (4) Composite key (entity ID + sequence) in server, human-readable slug only for authored content. -- **Constraints:** Line IDs must be globally unique across entire save file lifetime (history log readiness). Must stay human-readable for hand-authored content. Server treats IDs as opaque strings — solution lives in content/generation layer. Must be compatible with D-035 NPC-scoped namespace. +- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override) +- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged. +- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25. - **Ticket:** #544 - **Assigned to:** Gestalt, Tyre - **Source:** Sprint 16 PR #59 review discussion (2026-02-23) +### Q-029: Save file format design +- **Status:** Open +- **Question:** What should the long-term save file format look like? Key considerations: + 1. **Versioning and migration:** How do saves survive across game versions? Schema evolution strategy (field additions, renames, removals). Should saves embed a version number and run migrations on load? + 2. **Compression:** Raw MessagePack vs compressed (zstd, lz4)? Tradeoff between save/load speed and file size. SaveStateV1 is already MessagePack — does that carry forward? + 3. **Integrity:** Checksums or signatures to detect corruption? CRC32 header? + 4. **Metadata header:** Should the file have a readable header (game version, save date, play time, character name) that the loading screen can read without deserializing the full save? + 5. **Determinism:** D-010 requires deterministic simulation. Can saves capture enough state to resume deterministically, or is approximate resume acceptable? + 6. **Modding:** Should the format be documented for mod authors? Does it need extension points? + 7. **Cloud sync:** Any considerations for Steam Cloud or similar? File size limits? +- **Context:** Sprint 19 implements a quick-and-dirty save format (D-085 per-game directories, MessagePack serialization from SaveStateV1). This question tracks the thorough design pass for production quality. +- **Assigned to:** Tyre, Dudley +- **Source:** Team Leader directive (Sprint 19 planning) + +### 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* +### Q-045: Axis 11 — Network Footprint NPC tag +- **Status:** Open +- **Priority:** High +- **Question:** Should the NPC model (D-024, 10 axes) gain an 11th axis: `network_footprint: Option<NetworkFootprintTag>` for NPCs who are locally insignificant in appearance but carry network-significant information or are relevant to external actors? Default `None` for procedural NPCs. Explicitly set for authored scenario NPCs. This would enable the storyteller to identify locally-invisible but network-critical nodes without breaking the NPC's mundane character. +- **Context:** Raised during Generator Architecture Workshop (#562). The Ysabel Vorn litmus test (4.5/5 playstyle hooks, Backwater/Moderate setting) demonstrated that locally-insignificant NPCs can be key network nodes. Without this field, the generator has no mechanism to flag them to the storyteller. +- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §NPC Model. +- **Assigned to:** Miri + +### Q-046: Departure schedule model — departure windows as generator output for docked vessels +- **Status:** Resolved → D-108 (MobileChunk Specification) +- **Resolution:** `scheduled_departure: Option<SimTick>` in `Docked` state is mandatory generator output. Vessels without departure schedules are an error state. The `Docked` struct must include `docked_since: SimTick` and `scheduled_departure: Option<SimTick>` — these fields must be added at implementation time (absent from Tyre's Round 4 canonical struct). +- **Date resolved:** 2026-02-27 +- **Source:** Generator Architecture Workshop (#562) +- **Assigned to:** Tyre + Miri + +### Q-047: Mobile environment social arc — structural representation of journey timeline +- **Status:** Open +- **Priority:** Medium +- **Question:** How is the social arc of a mobile environment journey (BoundedLinear / BoundedMobile) represented structurally? The journey has a beginning (boarding, strangers), middle (established dynamic), and end (departure, relationship crystallized). What game structures capture this timeline and enable the storyteller to intervene? Does `TransitSocialModifier` need a journey-phase field? +- **Context:** Ozzie's player experience requirement from Generator Architecture Workshop (#562): "the journey must have a social arc — not just social presence." The stage+cast framing is correct; the formal structural representation is unspecified. +- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions. +- **Assigned to:** Miri + Gestalt + +### Q-048: DramaDensity enum naming — 3-level vs 5-level +- **Status:** Open +- **Priority:** Low +- **Question:** The Round 4 struct uses `Quiescent / Active / Intense` (3 levels). Round 3 proposed `Zero / Low / Medium / High / Flashpoint` (5 levels). Which should be canonical? Nigel's position: `Flashpoint` should be preserved as a distinct peak value — it is the storyteller's maximum-pressure instrument and should not collapse into `Intense`. If 3 levels are chosen for implementation simplicity, `Flashpoint` should still be the distinct peak name, not `Intense`. +- **Context:** ComplexityTier → DramaDensity ceiling (established): Full → any intensity; Moderate → Active max; Minimal → Quiescent max; Empty → Zero only. Naming must be consistent with these ceiling values. +- **Source:** Generator Architecture Workshop (#562), Rounds 3–4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions. +- **Assigned to:** Tyre + Gestalt + +### Q-049: ObjectTag vocabulary co-maintenance — Miri and Araminta shared dependency +- **Status:** Open +- **Priority:** Medium +- **Question:** The `ObjectTag` vocabulary must be co-maintained between Miri's `HeritageGrammarOverlay` (cultural grammar, Rust struct) and Araminta's asset categorization (visual expression, TOML files). What is the governance model? Who owns the canonical tag list? How are additions and deprecations coordinated? Does the vocabulary live in the Rust struct definition or in a shared data file? +- **Context:** If the vocabulary diverges, the generator will reference tags that don't exist in asset categories, or assets will be authored that the grammar never references. This is a silent correctness failure. +- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-9. +- **Assigned to:** Miri + Araminta + +### Q-050: Assassination difficulty synthesis — formal spec combining stored baseline with on-demand computation +- **Status:** Open +- **Priority:** Medium +- **Question:** Formal specification needed for the synthesis combining stored cultural baseline (`DerivedDistrictAnalysis` on Phase 1 skeleton) with on-demand runtime computation for player-facing assessment. Key constraint from Miri: **on-demand computation is display-only** — all game logic (tactical triangle instantiation, guarantee audit) uses the Phase 1 `DerivedDistrictAnalysis` value. The on-demand computation is subordinate to the stored baseline, not a replacement. +- **Context:** Minor tension between Gestalt's "computed entirely on demand" position and Miri's "stored cultural baseline" position. Synthesis accepted by both participants in Generator Architecture Workshop (#562); formal spec needed for implementation. +- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions. +- **Assigned to:** Gestalt + Miri + +--- + +*50 questions (12 resolved, 2 partially resolved, 36 open). Last updated: 2026-02-27 (Q-045 through Q-050 added — Generator Architecture Workshop #562; Q-046 resolved immediately by D-108)* diff --git a/decisions/scope.md b/decisions/scope.md index f109a050f..74a4a31d4 100644 --- a/decisions/scope.md +++ b/decisions/scope.md @@ -178,6 +178,33 @@ What we're building: game concept, design pillars, prototype definition, map spe - **Raised by:** Lead (smuggler needs inventory), Paula (three items + presentation split), Gestalt (knowledge-primary framework), Tyre (minimal implementation: SmallVec<3>), Dudley (server model: BTreeMap + info boundary) - **Dissent:** Tyre initially argued zero physical items in v0.1 (saves 3-4 sprints). Adapted with minimal implementation after lead directive. +### D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions +- **Date:** 2026-02-12 +- **Decision:** v0.1 vertical slice uses 5 relationship triangles. Three are active forks (T1: Kael-Smuggler-Ring, T2: Sera-Detective-Commission, T4: Drin-System-Ring) with branching outcomes driven by player observation. Two are passive tensions (T3: Naia-Kael-Hael, T5: Worried Partner background) that provide atmosphere and secondary discovery paths. Active forks require authored content per branch. Passive tensions are system-driven. +- **Rationale:** Three active forks are within v0.1 content authoring capacity. Passive tensions require no branching content — they enrich discovery space without multiplying authored lines. +- **Raised by:** Gestalt, Paula +- **Dissent:** None +- **Source:** v0.1 Content Scoping Workshop, Round 2 synthesis +- **Cross-reference:** D-027 (vertical slice), D-034 (THE FRIEND pattern) + +### D-089: Self-contained triangle forks for v0.1, no cross-triangle cascade +- **Date:** 2026-02-12 +- **Decision:** In v0.1, each triangle fork resolves independently. No triangle outcome triggers escalation in another triangle. Cross-triangle cascade (storyteller-managed, where resolving T1 affects T2 pressure) is deferred to v0.2+. This keeps v0.1 content authoring manageable — each triangle is a self-contained narrative unit. +- **Rationale:** Cross-triangle cascade requires the storyteller to track inter-triangle state and authors to write contingent branches. Both are out of scope for v0.1. Self-contained triangles can be authored, tested, and validated independently. +- **Raised by:** Paula, Gestalt +- **Dissent:** None +- **Source:** v0.1 Content Scoping Workshop, Round 2 synthesis +- **Cross-reference:** D-087 (triangle configuration), D-027 (vertical slice) + +### D-091: Complicity as named thematic core +- **Date:** 2026-02-12 +- **Decision:** The game's thematic identity is complicity — not conspiracy, not detection, not information asymmetry (which is the mechanical core per D-007). The player becomes complicit through observation: seeing something means choosing whether to act on it. The smuggler is complicit in the ring's operations. The detective is complicit in the institution's blindness. Both discover they are already entangled before they choose to be. This framing governs narrative design, wow moment emotional targets (D-039), and the Divergence Reveal (D-027 criterion 4). +- **Rationale:** "Complicity" names the emotional experience that information asymmetry produces. It distinguishes this game from pure detective games (you uncover truth) and pure action games (you do things). Here: you watch, and the watching implicates you. +- **Raised by:** Gore +- **Dissent:** None +- **Source:** Wiki Review Workshop, Gore Round 2 proposal, confirmed by lead interview +- **Cross-reference:** D-007 (five pillars), D-039 (wow moments), D-027 (vertical slice) + --- -*14 decisions (12 active, 2 superseded). Last updated: 2026-02-13* +*17 decisions (15 active, 2 superseded). Last updated: 2026-02-12 (D-087, D-089, D-091 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)* diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 21d44a440..0c2e88f73 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -13,7 +13,7 @@ decisions/ Decision domain files (source of truth for all D/Q/R entri .config/ Configuration files (linters, formatters, CI) .cache/ Local caches for testing/linting (gitignored) docs/ Design, architecture, briefings, workshops -db/ SQLite ticketing + decisions database and connectors +db/ SQLite schema + seed data (connectors at tooling/db/) ``` Unit tests live inside their respective projects (`server/` uses `#[cfg(test)]` inline + `tests/` directory per D-030). The top-level `tests/` directory is for integration tests that cross the client-server boundary (IPC round-trip, serialization fixtures, divergence tests). @@ -62,11 +62,27 @@ The server must be running before the client connects (subprocess launch will be ### Test ```bash -make test # Run all tests -make test-server # cargo test in server/ -make test-client # gdUnit4 tests (headless runner pending) +make test # Run all tests (test-server + test-client) +make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary) +make test-client # Godot tests via tests/run-godot (gdUnit4 headless, JSON summary) ``` +The IPC test layers (D-030) have dedicated targets: + +```bash +make test-ipc-fixtures # Layer 1: serialization round-trip fixtures +make test-ipc-protocol # Layer 2: mock LocalBridge protocol tests +make test-ipc-integration # Layer 3: real subprocess round-trip (+ benchmark when ready) +make test-ipc-benchmark # IPC latency benchmark (blocked: #555/#556 handshake) +``` + +Each `tests/run-*` script outputs a JSON summary to stdout and streams progress to stderr: +```json +{"suite":"rust","total":42,"passed":42,"failed":0,"duration_ms":1230} +``` + +All scripts accept `--filter <name>` to run a subset of tests. They are whitelistable for agent use (no TTY prompts, no interactive input). + Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030). ### Cross-Encoder Fixtures @@ -253,17 +269,17 @@ Key components: Use wrapper scripts: ```bash -db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='open'" -db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1" +tooling/db/sqlite-query "SELECT * FROM tickets WHERE status='open'" +tooling/db/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1" ``` ## Qdrant / Document Search ```bash -db/connectors/qdrant-search "asymmetric information design" -db/connectors/qdrant-index docs/briefings/tyre.md -db/connectors/qdrant-health -db/connectors/qdrant-count +tooling/db/qdrant-search "asymmetric information design" +tooling/db/qdrant-index docs/briefings/tyre.md +tooling/db/qdrant-health +tooling/db/qdrant-count ``` ## Decisions System diff --git a/docs/backups/settledreach.db.backup b/docs/backups/settledreach.db.backup index 9b99d7f56..6a567e64a 100644 Binary files a/docs/backups/settledreach.db.backup and b/docs/backups/settledreach.db.backup differ diff --git a/docs/design/line-id-authoring-guide.md b/docs/design/line-id-authoring-guide.md new file mode 100644 index 000000000..7373df6fd --- /dev/null +++ b/docs/design/line-id-authoring-guide.md @@ -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* diff --git a/docs/design/npc-patterns/the-mirror.md b/docs/design/npc-patterns/the-mirror.md index 7fb197614..7c489d525 100644 --- a/docs/design/npc-patterns/the-mirror.md +++ b/docs/design/npc-patterns/the-mirror.md @@ -626,7 +626,7 @@ In a world where everyone is hiding something, the one person who isn't becomes **End Pattern Specification.** **Files referenced:** -- `/var/home/jeroenschweitzer/Projects/settled-reach/copy/wiki/npcs/naia-tamm.md` — Reference implementation -- `/var/home/jeroenschweitzer/Projects/settled-reach/copy/decisions/content.md` — D-024 (10-axis model), D-028 (dialogue architecture), D-029 (entanglement ratio), D-032 (separate monologue pools), D-034 (THE FRIEND pattern), D-035 (tag taxonomy) -- `/var/home/jeroenschweitzer/Projects/settled-reach/copy/docs/workshops/v01-content-scoping/round2-gestalt.md` — Pattern definitions and NPC mapping -- `/var/home/jeroenschweitzer/Projects/settled-reach/copy/docs/workshops/v01-gap-analysis/round2-gestalt.md` — Unified observation system, character identity integration +- `/var/mnt/data/projects/settled-reach/copy/wiki/npcs/naia-tamm.md` — Reference implementation +- `/var/mnt/data/projects/settled-reach/copy/decisions/content.md` — D-024 (10-axis model), D-028 (dialogue architecture), D-029 (entanglement ratio), D-032 (separate monologue pools), D-034 (THE FRIEND pattern), D-035 (tag taxonomy) +- `/var/mnt/data/projects/settled-reach/copy/docs/workshops/v01-content-scoping/round2-gestalt.md` — Pattern definitions and NPC mapping +- `/var/mnt/data/projects/settled-reach/copy/docs/workshops/v01-gap-analysis/round2-gestalt.md` — Unified observation system, character identity integration diff --git a/docs/design/sova-station-profile.md b/docs/design/sova-station-profile.md index 874c49929..89eb97812 100644 --- a/docs/design/sova-station-profile.md +++ b/docs/design/sova-station-profile.md @@ -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. -**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. --- diff --git a/docs/design/spatial-layout-gate-v01.md b/docs/design/spatial-layout-gate-v01.md new file mode 100644 index 000000000..a0a751332 --- /dev/null +++ b/docs/design/spatial-layout-gate-v01.md @@ -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." diff --git a/docs/design/tier1-module-authoring.md b/docs/design/tier1-module-authoring.md new file mode 100644 index 000000000..3606139f3 --- /dev/null +++ b/docs/design/tier1-module-authoring.md @@ -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).* diff --git a/docs/design/wireframes/README.md b/docs/design/wireframes/README.md index 0d9e1e44f..0eae4aa9e 100644 --- a/docs/design/wireframes/README.md +++ b/docs/design/wireframes/README.md @@ -60,7 +60,7 @@ Main menu, pause, save/load, options. |-----------|---------|-------------------| | [v01-main-menu](menus/v01-main-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (functional warmth style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (two-character proof — character select) | | [v01-pause-menu](menus/v01-pause-menu.png) | v0.1 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) | -| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style) | +| [v01-save-load](menus/v01-save-load.png) | v0.1 | [D-085](../../../decisions/architecture.md#d-085-per-game-save-directory-structure) (per-game save dirs), [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-027](../../../decisions/scope.md#d-027-vertical-slice--smuggler--detective-two-character-proof) (vertical slice) | | [v10-main-menu](menus/v10-main-menu.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-036](../../../decisions/content.md#d-036-sova-transit-district--krenn-system-as-v01-setting) (setting — Sova Transit District), [D-013](../../../decisions/scope.md#d-013-diegetic-insertpoi-navigation-system) (diegetic insert — in-fiction menu) | | [v10-options-full](menus/v10-options-full.png) | v1.0 | [D-043](../../../decisions/perception.md#d-043-art-direction--visual-style-functional-warmth) (visual style), [D-068](../../../decisions/architecture.md#d-068-5-bus-audio-architecture) (5-bus audio — per-bus volume controls), [D-069](../../../decisions/perception.md#d-069-audio-dip-profiles-for-dialogue-and-confrontation) (audio dip profiles) | diff --git a/docs/design/wireframes/menus/v01-save-load.json b/docs/design/wireframes/menus/v01-save-load.json index 60a39096c..65fb789b9 100644 --- a/docs/design/wireframes/menus/v01-save-load.json +++ b/docs/design/wireframes/menus/v01-save-load.json @@ -37,156 +37,220 @@ "tab-save": { "type": "Rectangle", "left": 140, "top": 116, "width": 120, "height": 32, - "fillColor": "#1a2030", - "strokeColor": "#c8d0e0", + "fillColor": "#0d1018", + "strokeColor": "#333340", "corners": [2, 2, 0, 0] }, "tab-save-text": { "type": "Text", "left": 156, "top": 125, "text": "SAVE", - "fontColor": "#c8d0e0", + "fontColor": "#556677", "fontSize": 13 }, "tab-load": { "type": "Rectangle", "left": 264, "top": 116, "width": 120, "height": 32, - "fillColor": "#0d1018", - "strokeColor": "#333340", + "fillColor": "#1a2030", + "strokeColor": "#c8d0e0", "corners": [2, 2, 0, 0] }, "tab-load-text": { "type": "Text", "left": 280, "top": 125, "text": "LOAD", - "fontColor": "#556677", + "fontColor": "#c8d0e0", "fontSize": 13 }, - "save-slot-1-active": { + + "game-1-header": { "type": "Rectangle", - "left": 140, "top": 156, "width": 860, "height": 72, + "left": 140, "top": 156, "width": 860, "height": 32, + "fillColor": "#151a24", + "strokeColor": "#333340", + "corners": [2, 2, 0, 0] + }, + "game-1-title": { + "type": "Text", + "left": 152, "top": 165, + "text": "\u25bc DETECTIVE \u2014 Day 3 // Sova Transit // last played: today", + "fontColor": "#c8d0e0", + "fontSize": 12 + }, + "game-1-count": { + "type": "Text", + "left": 920, "top": 165, + "text": "3 saves", + "fontColor": "#556677", + "fontSize": 11 + }, + + "qs-row": { + "type": "Rectangle", + "left": 158, "top": 192, "width": 842, "height": 62, "fillColor": "#1a2030", "strokeColor": "#c8d8f0", "corners": [2, 2, 2, 2] }, - "save-slot-1-accent": { + "qs-accent": { "type": "Rectangle", - "left": 140, "top": 156, "width": 3, "height": 72, + "left": 158, "top": 192, "width": 3, "height": 62, "fillColor": "#c8d8f0", "strokeColor": "#c8d8f0" }, - "slot-1-date": { + "qs-label": { "type": "Text", - "left": 152, "top": 162, - "text": "AUTOSAVE // Day 1, 07:42", + "left": 170, "top": 200, + "text": "QUICKSAVE // Day 3, 14:22", "fontColor": "#c8d0e0", "fontSize": 13 }, + "qs-location": { + "type": "Text", + "left": 170, "top": 218, + "text": "The Terminal \u2014 afternoon shift", + "fontColor": "#8899aa", + "fontSize": 12 + }, + "qs-timestamp": { + "type": "Text", + "left": 170, "top": 234, + "text": "saved: 2026-02-25 16:31", + "fontColor": "#556677", + "fontSize": 11 + }, + "qs-actions": { + "type": "Text", + "left": 840, "top": 212, + "text": "[Enter] Load\n[F6] Quickload", + "fontColor": "#c8d8f0", + "fontSize": 11 + }, + + "auto-row": { + "type": "Rectangle", + "left": 158, "top": 260, "width": 842, "height": 62, + "fillColor": "#0e1118", + "strokeColor": "#333340", + "corners": [2, 2, 2, 2] + }, + "auto-label": { + "type": "Text", + "left": 170, "top": 268, + "text": "AUTOSAVE // Day 3, 13:45", + "fontColor": "#8899aa", + "fontSize": 13 + }, + "auto-location": { + "type": "Text", + "left": 170, "top": 286, + "text": "Corridor B-7", + "fontColor": "#556677", + "fontSize": 12 + }, + "auto-timestamp": { + "type": "Text", + "left": 170, "top": 302, + "text": "saved: 2026-02-25 16:15", + "fontColor": "#3a4455", + "fontSize": 11 + }, + + "slot-1-row": { + "type": "Rectangle", + "left": 158, "top": 328, "width": 842, "height": 62, + "fillColor": "#0e1118", + "strokeColor": "#333340", + "corners": [2, 2, 2, 2] + }, + "slot-1-label": { + "type": "Text", + "left": 170, "top": 336, + "text": "SLOT 1 // Day 2, 22:10", + "fontColor": "#8899aa", + "fontSize": 13 + }, "slot-1-location": { "type": "Text", - "left": 152, "top": 180, - "text": "The Terminal — morning shift // Detective", - "fontColor": "#8899aa", + "left": 170, "top": 354, + "text": "Hab quarters \u2014 evening", + "fontColor": "#556677", "fontSize": 12 }, "slot-1-timestamp": { "type": "Text", - "left": 152, "top": 198, - "text": "saved: 2026-02-23 14:31", - "fontColor": "#556677", + "left": 170, "top": 370, + "text": "saved: 2026-02-25 14:48", + "fontColor": "#3a4455", "fontSize": 11 }, - "slot-1-actions": { - "type": "Text", - "left": 860, "top": 175, - "text": "[Enter] Overwrite / Load", - "fontColor": "#c8d8f0", - "fontSize": 12 - }, - "save-slot-2": { + + "game-2-header": { "type": "Rectangle", - "left": 140, "top": 236, "width": 860, "height": 72, - "fillColor": "#0e1118", + "left": 140, "top": 404, "width": 860, "height": 32, + "fillColor": "#111520", "strokeColor": "#333340", "corners": [2, 2, 2, 2] }, - "slot-2-date": { + "game-2-title": { "type": "Text", - "left": 152, "top": 250, - "text": "SLOT 2 // Day 1, 06:15", + "left": 152, "top": 413, + "text": "\u25b6 SMUGGLER \u2014 Day 1 // The Terminal // last played: yesterday", "fontColor": "#8899aa", - "fontSize": 13 - }, - "slot-2-location": { - "type": "Text", - "left": 152, "top": 268, - "text": "Arrival — entering Sova Transit // Detective", - "fontColor": "#556677", "fontSize": 12 }, - "slot-2-timestamp": { + "game-2-count": { "type": "Text", - "left": 152, "top": 286, - "text": "saved: 2026-02-23 13:10", + "left": 920, "top": 413, + "text": "2 saves", "fontColor": "#3a4455", "fontSize": 11 }, - "save-slot-3": { + + "game-3-header": { "type": "Rectangle", - "left": 140, "top": 316, "width": 860, "height": 72, - "fillColor": "#0e1118", + "left": 140, "top": 444, "width": 860, "height": 32, + "fillColor": "#111520", "strokeColor": "#333340", "corners": [2, 2, 2, 2] }, - "slot-3-date": { + "game-3-title": { "type": "Text", - "left": 152, "top": 330, - "text": "SLOT 3 // Day 1, 07:30", + "left": 152, "top": 453, + "text": "\u25b6 DETECTIVE \u2014 Day 7 // Sova Transit // last played: Feb 20", "fontColor": "#8899aa", - "fontSize": 13 - }, - "slot-3-location": { - "type": "Text", - "left": 152, "top": 348, - "text": "Corridor B-7 — Kael spotted // Smuggler", - "fontColor": "#556677", "fontSize": 12 }, - "slot-3-timestamp": { + "game-3-count": { "type": "Text", - "left": 152, "top": 366, - "text": "saved: 2026-02-23 12:48", + "left": 920, "top": 453, + "text": "5 saves", "fontColor": "#3a4455", "fontSize": 11 }, - "empty-slots-label": { + + "footer-note": { "type": "Text", - "left": 140, "top": 400, - "text": "SLOTS 4-8 — empty", - "fontColor": "#2a3040", - "fontSize": 12 - }, - "save-note": { - "type": "Text", - "left": 140, "top": 660, - "text": "Autosave on: zone transitions, conversation ends, significant events", + "left": 140, "top": 660, "width": 860, + "text": "Autosave: zone transitions, conversation ends, significant events // F5 quicksave // F6 quickload", "fontColor": "#3a4455", - "fontSize": 11 + "fontSize": 11, + "wordWrap": true }, "annotation-title": { "type": "Text", "left": 16, "top": 720, - "text": "v0.1 SAVE / LOAD SCREEN", + "text": "v0.1 SAVE / LOAD \u2014 LOAD TAB (D-085)", "fontColor": "#556677", "fontSize": 11 }, "annotation-notes": { "type": "Text", "left": 16, "top": 734, "width": 1100, - "text": "Tab UI: Save / Load. Slots show: timestamp, in-game time+location, character. Autosave shown separately. Active slot has accent bar. No screenshots in v0.1.", + "text": "Games grouped by directory (D-085). Expand to see saves. QUICKSAVE + AUTOSAVE are system slots; manual slots below. SAVE tab shows current game only. F5/F6 global hotkeys for quicksave/quickload.", "fontColor": "#3a4455", "fontSize": 10, "wordWrap": true } } -} +} \ No newline at end of file diff --git a/docs/diagrams/design/district-topology.d2 b/docs/diagrams/design/district-topology.d2 new file mode 100644 index 000000000..08b10773c --- /dev/null +++ b/docs/diagrams/design/district-topology.d2 @@ -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 +} diff --git a/docs/diagrams/design/district-topology.png b/docs/diagrams/design/district-topology.png new file mode 100644 index 000000000..d0644d5f6 Binary files /dev/null and b/docs/diagrams/design/district-topology.png differ diff --git a/docs/discussions/README.md b/docs/discussions/README.md index c90933efe..b91a41a39 100644 --- a/docs/discussions/README.md +++ b/docs/discussions/README.md @@ -23,3 +23,4 @@ Historical discussion rounds from the Commonwealth game design process. | 17 | Content Architecture | D-023, D-024, D-025, D-026, D-027, D-028, D-029 | [round-17](round-17-content-architecture.md) | | 18 | v0.1 Gap Analysis Workshop | D-030, D-031, D-032, D-033, D-034, D-035, D-036, D-037, D-038, D-039, D-040 | [round-18](round-18-v01-gap-analysis-workshop.md) | | 19 | Knowledge Graph & Information Boundaries Workshop | D-041, Q-016 resolved, Q-019 partially resolved, Q-024, Q-025, Q-026 | [workshop brief](../workshops/knowledge-graph-information-boundaries/workshop-brief.md), [synthesis](../workshops/knowledge-graph-information-boundaries/round2-synthesis.md) | +| 20 | Station District Layout Design (Workshop #153) | D-093, D-094, D-095; Q-040–Q-044 resolved/partially resolved | [round-20](round-20-station-district-layout.md) | diff --git a/docs/discussions/decisions-restructure-si.md b/docs/discussions/decisions-restructure-si.md index 41e69b0aa..4be76be1f 100644 --- a/docs/discussions/decisions-restructure-si.md +++ b/docs/discussions/decisions-restructure-si.md @@ -791,7 +791,7 @@ This path reduces risk by deferring sync tooling until domain split is validated **End of analysis.** **File locations:** -- Analysis: `/var/home/jeroenschweitzer/Projects/commonwealth/docs/discussions/decisions-restructure-si.md` -- Current monolith: `/var/home/jeroenschweitzer/Projects/commonwealth/DECISIONS.md` (474 lines) -- Current schema: `/var/home/jeroenschweitzer/Projects/commonwealth/db/schema.sql` +- Analysis: `/var/mnt/data/projects/commonwealth/docs/discussions/decisions-restructure-si.md` +- Current monolith: `/var/mnt/data/projects/commonwealth/DECISIONS.md` (474 lines) +- Current schema: `/var/mnt/data/projects/commonwealth/db/schema.sql` - Current tickets: 273 total (24 initiatives, 33 epics, 200 stories, 15 tasks, 1 bug) diff --git a/docs/discussions/decisions-restructure-tyre.md b/docs/discussions/decisions-restructure-tyre.md index 38faad377..b61c1ce2f 100644 --- a/docs/discussions/decisions-restructure-tyre.md +++ b/docs/discussions/decisions-restructure-tyre.md @@ -461,12 +461,12 @@ Assign me the sync script, schema additions, and Makefile targets. I can have Ph --- **File locations referenced:** -- This review: `/var/home/jeroenschweitzer/Projects/commonwealth/docs/discussions/decisions-restructure-tyre.md` -- Si's analysis: `/var/home/jeroenschweitzer/Projects/commonwealth/docs/discussions/decisions-restructure-si.md` -- Qatux's analysis: `/var/home/jeroenschweitzer/Projects/commonwealth/docs/discussions/decisions-restructure-qatux.md` -- Current schema: `/var/home/jeroenschweitzer/Projects/commonwealth/db/schema.sql` -- Current decisions: `/var/home/jeroenschweitzer/Projects/commonwealth/DECISIONS.md` -- Makefile: `/var/home/jeroenschweitzer/Projects/commonwealth/Makefile` -- SQLite connector: `/var/home/jeroenschweitzer/Projects/commonwealth/db/connectors/sqlite_connector.py` +- This review: `/var/mnt/data/projects/commonwealth/docs/discussions/decisions-restructure-tyre.md` +- Si's analysis: `/var/mnt/data/projects/commonwealth/docs/discussions/decisions-restructure-si.md` +- Qatux's analysis: `/var/mnt/data/projects/commonwealth/docs/discussions/decisions-restructure-qatux.md` +- Current schema: `/var/mnt/data/projects/commonwealth/db/schema.sql` +- Current decisions: `/var/mnt/data/projects/commonwealth/DECISIONS.md` +- Makefile: `/var/mnt/data/projects/commonwealth/Makefile` +- SQLite connector: `/var/mnt/data/projects/commonwealth/db/connectors/sqlite_connector.py` **End of technical review.** diff --git a/docs/discussions/round-20-station-district-layout.md b/docs/discussions/round-20-station-district-layout.md new file mode 100644 index 000000000..8988dc539 --- /dev/null +++ b/docs/discussions/round-20-station-district-layout.md @@ -0,0 +1,663 @@ +# Round 20: Station District Layout Design — Ticket #153 + +**Sprint:** 20 (Shape) +**Date:** 2026-02-25 +**Ticket:** #153 — Station district layout design +**Participants:** Gestalt, Miri, Araminta, Tyre, Paula, Ozzie, Qatux (documenter) +**Output target:** D-record in `decisions/content.md` or `decisions/architecture.md` +**Blocks:** #155, #188 + +--- + +## Internal Round Structure + +| Internal Round | Topic | Status | +|----------------|-------|--------| +| Round 1 | Inventory and constraints | Complete — see §1 below | +| Round 2 | Topology proposals | Pending | +| Round 3 | Convergence and D-record draft | Pending | + +--- + +## §1 — ROUND 1: Constraints Summary + +*Compiled by Qatux. Derived from 6 agent contributions: Ozzie, Gestalt, Miri, Tyre, Araminta, Paula.* + +**Lead note (pre-round):** The spatial patterns decided here establish the district template that Q-036's generator will eventually use. Decisions here become architectural precedent — not just v0.1 configuration. + +**Qatux framing:** Constraints below are tagged: +- `[v0.1]` — specific to the hand-authored Sova Transit District +- `[template]` — generalisable to any future generated district of this type +- `[both]` — applies at both levels + +Cross-reference: Q-036 (district skeleton as generator output) tracks where template decisions need formal specification. + +--- + +### 1. Hard Constraints +*Non-negotiable: confirmed decisions, confirmed technical facts, Paula's structural requirements that block narrative arcs if violated.* + +| # | Constraint | Source | Tag | Cross-ref | +|---|------------|--------|-----|-----------| +| H-01 | Dual-scale grid: 0.5m sim tiles, 1m visual tiles. All tile counts in this document are **visual tiles** unless noted. | D-066 | both | D-066 | +| H-02 | Tile-based movement. All spatial reasoning is discrete. Corridors must be ≥1 visual tile wide; functional spaces ≥2 tiles wide. | D-054 | both | D-054 | +| H-03 | Fog zone temperature tint is already decided: Terminal = cool dark, Bar = warm dark, corridors = neutral dark. The gate cluster adds a fourth zone requiring a tint assignment. | D-059 | v0.1 | D-059 | +| H-04 | Local map budget: ~150×150 tiles. Tyre confirms current layouts use ≤10% of this budget. Performance is not a binding constraint at current scope. | D-014, Tyre | both | D-014 | +| H-05 | Social sites must be connected spaces of 15–40 tiles. | D-025 | template | D-025 | +| H-06 | Access tiers must be traversed **sequentially** — public → semi-public → restricted. No spatial path may allow skipping a tier. | Gestalt | template | D-025 | +| H-07 | Restricted storage has exactly **one** entrance (the coded door from the cargo floor). No second entrance, no back exit. | Paula | v0.1 | #311 | +| H-08 | Maintenance corridor has **zero LOS** from all other spaces. Its interior is not visible from any public or semi-public zone. Detection requires: sound propagation (footsteps on grating) or witness at hatch entry/exit points only. | Paula, smuggling layout | v0.1 | #313 | +| H-09 | No third route between Terminal and Bar. Exactly two routes exist: (a) the public transition corridor and (b) the maintenance corridor (ring-only, private). A third route would dissolve the ring's movement asymmetry. | Paula | v0.1 | #313 | +| H-10 | Bathroom corridor interior has **zero LOS** from the bar floor. Observer at bar can see only the door, not the interior. | Paula, bar layout | v0.1 | #312 | +| H-11 | No private path between manifest processing and supervisor's office. Maret's crossing of the main corridor is a public act with narrative cost — visibility is the spatial mechanism. | Paula, terminal layout | v0.1 | #311 | +| H-12 | Chunk size decision required. Tyre recommends **32×32 sim tiles** (= 16×16 visual tiles) as the generation unit. Zone and access-tier boundaries should align to chunk edges where possible. | Tyre | template | D-073, Q-036 | +| H-13 | Z-level allocation decision required. Tyre recommends maintenance corridor on **z=0**, bar/terminal structures on **z=1**. No additional z-levels without gameplay justification. | Tyre, Araminta | both | D-049 | + +--- + +### 2. Setting Constraints +*What the Sova station profile and worldbuilding require.* + +| # | Constraint | Source | Tag | +|---|------------|--------|-----| +| S-01 | Span gate at Terminal's **north face**. Freight enters from north (inbound cargo from span gate); maintenance exits south. This is directional — north = external transit, south = district interior. | Miri, terminal layout | v0.1 | +| S-02 | **Two separate entry vectors** into the district: (a) freight span gate and (b) commuter transit connection. Passengers and freight do not share the same entry point. | Miri | template | +| S-03 | Sealed hull-section boundary. The district has a **finite, countable number of choke-point exits** — not open-ended. The player can know all exits. | Miri | template | +| S-04 | Sector 3 is a named sub-area of the district with an unresolved ventilation issue. Must be referenced spatially — it has a location, even if not a full social site. | Miri | v0.1 | +| S-05 | Maintenance spine is **Era 1 construction** — it predates the current buildings. The spine's route is fixed; buildings were placed around it. This explains: grating floors, cold-white sparse lighting, no Meridian coverage, and why the ring chose it. | Miri | v0.1 | +| S-06 | Meridian coverage follows construction era gradient: new construction (gate cluster) = good coverage, mixed era (Terminal, Bar area) = degraded, Era 1 (maintenance spine) = none. | Miri | template | +| S-07 | 800 permanent population = compact district. Walking distances are short. Everything is known. | Miri | v0.1 | +| S-08 | Back room alley exit leads to the **district edge** (maintenance alley). It is not an interior district route — it accesses the hull boundary, enabling exit without crossing public district space. | Paula, bar layout | v0.1 | +| S-09 | Voss does not appear at the bar. The spatial separation of Triangles 1/2 (Terminal-based) from Triangles 3/4 (Bar-adjacent) is architecturally enforced by Voss's absence from the bar. | Paula | v0.1 | +| S-10 | Maintenance corridor carries **zero regular traffic**. It must be architecturally believable that no worker has reason to enter — it connects restricted storage to the bar's bathroom corridor. That route has no legitimate use. | Paula | v0.1 | + +--- + +### 3. Gameplay Constraints +*What the four gameplay loops require from the spatial layout.* + +| # | Constraint | Source | Tag | +|---|------------|--------|-----| +| G-01 | District must support **four gameplay loops** simultaneously: investigation, social observation, smuggling, daily life. Each loop requires distinct spatial affordances that do not conflict. | Gestalt | template | +| G-02 | **Minimum 4–6 social sites** in the district. Confirmed: Terminal (1), Bar (1), Gate cluster (to design, 1). Remaining 1–3 sites are unspecified. | Gestalt | template | +| G-03 | **Minimum 2 NPC route convergence points** outside Terminal and Bar — locations where NPCs from different social sites share a path, enabling observation of cross-site relationships. | Gestalt | template | +| G-04 | Gate cluster requires its own **social triangle** (≥3 NPCs with conflicting interests). This is not just a transit hub — it is a social site with investigation affordances. | Gestalt | template | +| G-05 | **District entry = fork**. Player's first spatial decision is directional: left or right, Terminal or Bar side. Both directions are legitimate from moment one. No forced tutorial path. | Ozzie | template | +| G-06 | Transition corridor crossing: **20–25 seconds** at Walk stance. Long enough to be a temporal beat; short enough not to become friction. At Walk (1 tile / 2 ticks, 10 tps) = 5 tiles per second = 100–125 tiles at 20–25s. The corridor as designed (~40m = 40 visual tiles) achieves ~8 seconds — **this is a gap that needs resolving in Round 2** (see Open Tensions T-08). | Ozzie, smuggling layout | v0.1 | +| G-07 | Maintenance corridor is a **gradual unlock**, not a sudden discovery. Player should be able to infer its existence (sound, NPC behavior anomaly, spatial hint) before accessing it physically. | Ozzie | template | +| G-08 | Every ring-associated location must **read as mundane on first pass**. "Nothing looks wrong" must be achievable without prior knowledge. The ring's spatial design principle (from smuggling layout): same physical space, different player understanding. | Ozzie, smuggling layout | template | +| G-09 | Generator district skeleton = **social site positions + access tier topology + traffic routing + observation position set + chokepoint designation**. This is the minimum specification for a generated district to be functional for all four gameplay loops. | Gestalt | template | +| G-10 | Zone-chunk alignment: audio zone transitions (D-073) and access tier transitions should coincide with chunk boundaries where possible, enabling the generator to reason about zones at chunk granularity. | Tyre | template | + +--- + +### 4. Narrative Constraints +*What the five triangle arcs require from the spatial layout.* + +| # | Constraint | Source | Tag | +|---|------------|--------|-----| +| N-01 | Triangle 4 (Drin–System–Ring) **spans both buildings**. Sera Venn needs a Commission inspection presence at the Terminal — a legitimate reason to be there that doesn't read as suspicious. The spatial layout must provide a Commission inspection point in the Terminal district. | Paula | v0.1 | +| N-02 | No private path between manifest processing and supervisor's office (see H-11). Reiterated here: the spatial cost of Maret choosing to act is the *visibility* of crossing the main corridor. | Paula | v0.1 | +| N-03 | No second entrance to restricted storage (see H-07). The ring's chokepoint is architectural — not a character choice. | Paula | v0.1 | +| N-04 | Bathroom corridor interior zero LOS from bar floor (see H-10). Private ring exchanges in the corridor must be invisible to observers on the bar floor. | Paula | v0.1 | +| N-05 | Exactly two routes between Terminal and Bar (see H-09). The public corridor is the ring's exposure; the maintenance corridor is their bypass. A third route dissolves this asymmetry. | Paula | v0.1 | +| N-06 | Back room alley exit to district edge (see S-08). Enables ring operational flow from bar side without re-crossing bar floor. | Paula | v0.1 | +| N-07 | Voss stays in Terminal spatial zone (see S-09). Triangle 1 and Triangle 2 are Terminal dramas; Triangle 3 is a Bar drama. Voss's absence from the bar is what keeps them separate. | Paula | v0.1 | +| N-08 | Maintenance corridor must be genuinely zero-traffic (see S-10). If workers ever had legitimate reason to use it, the ring's use would not be anomalous. | Paula | v0.1 | +| N-09 | **Path B discovery** (exploration-heavy investigation path) requires the maintenance hatch to be **discoverable from the break room area**. The terminal layout shows break room (southwest) and restricted storage (south-center) as adjacent structures, but the hatch (M-HATCH-T) opens inside restricted storage, not the break room. This is Paula's key topological issue — see Open Tensions T-01. | Paula | v0.1 | + +--- + +### 5. Player Experience Constraints +*What feel and navigation require.* + +| # | Constraint | Source | Tag | +|---|------------|--------|-----| +| P-01 | **Fork at district entry.** First decision is spatial. Terminal to the left, Bar to the right (or some equivalent directionality). The fork must be immediate and legible — no single entry corridor that forces the player through one building first. | Ozzie | template | +| P-02 | Transition corridor is a **tonal between-space** — not dead space. Moving through it is a beat of reflection: leaving one social temperature (Terminal = institutional cool), entering another (Bar = warm amber). 20–25s at Walk stance is the target duration. | Ozzie | template | +| P-03 | Main corridor at the Terminal must **feel dangerous**. Not literally — no combat threat — but the player should feel observed and out of place if they linger. NPC density, the supervisor's window, the chokepoint geometry all combine to produce this. | Ozzie | v0.1 | +| P-04 | Corner booth in the Bar is the **primary investigation observation post**. It must maintain LOS to: entrance, news ticker cluster, bar counter, card table, and back room door. Confirmed by bar layout. Any district modification that disrupts this LOS set breaks the investigation hub. | Ozzie, bar layout | v0.1 | +| P-05 | Maintenance corridor discovery should follow an **inference arc**: player hears something, notices NPC timing anomaly, finds the hatch, enters. Discovery should feel like a reveal, not a stumble. | Ozzie | template | +| P-06 | The "nothing looks wrong" moment — when the player first realizes the mundane spaces are the criminal infrastructure — should emerge from **accumulated observation**, not a single clue. Spatial design must support layered discovery: visit 1 = normal, visit 2 = curious, visit 3 = understood. | Ozzie | template | + +--- + +### 6. Visual / Spatial Constraints +*What the tilemap, art direction, and zone palette require.* + +| # | Constraint | Source | Tag | +|---|------------|--------|-----| +| V-01 | Terminal facade (44m wide) requires a **forecourt** — breathing space between the building face and the transition corridor or district spine. The facade cannot abut a corridor directly. | Araminta | v0.1 | +| V-02 | Bar east extension (bathroom corridor, 6m) **faces north**. This fixes the bar's relative orientation: the bathroom corridor opens northward toward the main transit area. The alley exit (south door, row 20 in bar layout) faces the district edge. | Araminta | v0.1 | +| V-03 | Transition corridor requires **widening zones** at both ends — spatial decompression before entering the Terminal forecourt and before entering the Bar entry. Narrow corridor expanding to wide forecourt reads as "arrival." | Araminta | template | +| V-04 | Gate cluster zone palette: **coolest and newest** in district. Era 3 construction, Commission-grade maintenance. Visually distinct from Terminal (cool grey-navy) and Bar (warm amber). Exact palette TBD in Round 2. | Araminta | v0.1 | +| V-05 | **Minimum corridor widths** by type (exact values to be specified in Round 2): maintenance corridor = 2m (confirmed, smuggling layout), transition corridor = 6m (confirmed, smuggling layout), internal building corridors and secondary public corridors = TBD. | Araminta | template | +| V-06 | **LOS anchors every ~4 visual tiles** in open spaces. Large open areas (forecourt, cargo floor, bar main floor) need furniture, pillars, kiosks, or fixtures at regular intervals. These serve dual purpose: visual rhythm and gameplay cover/observation points. | Araminta | template | +| V-07 | No additional **physical z-levels** unless gameplay-justified. Maintenance corridor on z=0, bar/terminal on z=1. Multi-floor structures require a gameplay reason (investigation access to a floor, combat routing). Cosmetic vertical variation is not sufficient justification. | Araminta, Tyre | both | +| V-08 | Zone temperature tints (D-059) must be **distinct and non-overlapping at transition boundaries**. Crossfade handled by D-073 (1.5–2s audio tween at hard tile boundary). Visual fog tint transition should use the same boundary — player should not be in two zone temperatures simultaneously. | D-059, D-073 | template | +| V-09 | Generator parameterization: minimum viable generator parameters for spatial layout include **facade width ratio** (building width : forecourt depth), **corridor width minimums by type**, **LOS anchor interval** (tiles between anchor objects in open spaces). | Araminta | template | + +--- + +### 7. Open Tensions +*Where constraints conflict or are underspecified — these are Round 2 discussion topics.* + +--- + +#### T-01 — PRIORITY: Break Room Adjacency / Path B Discovery +**What's at stake:** The detective's exploration-heavy investigation path (Path B from smuggling layout) begins with a floor worker in the break room mentioning Kael's odd hours, then the detective physically discovering the maintenance hatch. But the hatch (M-HATCH-T) opens inside *restricted storage*, not the break room. The break room (terminal layout rows 22–26, west) is southwest; restricted storage (rows 27–30, south-center) is south-center. They are adjacent but not connected. + +**Paula's three options:** +- **Option A — Shared wall with sound propagation.** Break room and restricted storage share an interior wall. A worker in the break room can hear sounds through the wall that hint at activity in the maintenance corridor (footsteps on grating). Player hears anomaly → investigates restricted storage → discovers hatch. +- **Option B — Disused side passage.** A disused (non-traversable) side passage between break room and restricted storage area gives physical proximity to the hatch area without creating a second route to restricted storage. +- **Option C — Path B starts on cargo floor.** A worker hears sounds near restricted storage while on the cargo floor (not the break room). Path B's starting location shifts from break room to cargo floor. + +**Why it needs resolving before Round 3:** The option chosen affects the terminal layout (wall topology between zones) and the district layout (is the break room at the southwest corner, or does it need repositioning?). **This is the highest-priority Round 2 discussion item.** + +**Generator implication [template]:** Which option generalizes? Option A (shared wall + sound propagation) is generalisable — it establishes "investigation paths can start with audio anomaly from adjacent zone." Options B and C are more specific to this layout. + +--- + +#### T-02 — Gate Cluster Scope and Triangle +**What's at stake:** Gestalt requires the gate cluster to be a full social site with its own triangle (≥3 NPCs). Miri requires freight/commuter flow separation at the gate. Together these imply the gate cluster is substantial — a fourth major location, not just a corridor junction. But no scope has been defined: how large? How many zones? What triangle roles? + +**The tension:** A large gate cluster is more content work than a small one. The district tile budget has room, but the content authoring budget (triangles, NPC lines) may not. Round 2 needs a concrete proposal for gate cluster size and triangle composition, or a decision to scope it down. + +**Generator implication [template]:** Gate cluster = entry node in district skeleton. Every freight district has one. The question is whether entry nodes always carry a social triangle or whether that's optional. If Q-036's district skeleton requires a triangle at every social site (Gestalt's minimum is 4–6 sites with triangles), entry nodes need triangle support. + +--- + +#### T-03 — Commuter Transit Entry Point Placement +**What's at stake:** Miri requires a separate commuter transit connection (not the span gate). This creates a second district entry point. Where it sits relative to the Terminal and Bar has large implications for NPC traffic patterns and ring exposure. + +**Two principal options:** +- **Option A — Gate-cluster-integrated.** Commuter transit is adjacent to the span gate — same spatial cluster, separate lanes. All inbound traffic (freight and passenger) arrives in the same zone, then fans out. This simplifies district topology but creates a single convergence point that's easier to monitor. +- **Option B — Separate entry point, Bar-side.** Commuter transit hub is on the bar side of the district (near the bar, away from the terminal). Workers arrive near their social space, not their workplace. This produces two active entry zones and richer NPC traffic routing — but complicates ring exposure analysis. + +**Generator implication [template]:** Two-vector entry is Miri's universal freight-district template. The generator needs to know whether the two vectors are co-located (same cluster) or distributed (separate district zones). This is a structural parameter. + +--- + +#### T-04 — Remaining Social Sites (1–3 Unidentified) +**What's at stake:** Gestalt requires 4–6 social sites. Confirmed: Terminal, Bar, gate cluster = 3. One to three more sites are unspecified. + +**Candidates from existing documentation:** +- Commuter transit hub (if Option B from T-03 — separate location with its own social dynamics) +- Sector 3 (Miri — named area with ventilation issue; has a location but no defined social site yet) +- Maintenance junction node (a secondary gathering point in the maintenance spine? Non-obvious) +- A commissary, clinic, or administrative sub-office (generic service space with resident NPCs) + +**What Round 2 needs:** Names and rough positions for the remaining social sites, or a decision to scope to 3 (Terminal + Bar + gate cluster) and justify why that satisfies Gestalt's minimum. Note: 3 may be sufficient if the gate cluster is large enough to function as 1.5 sites. + +**Generator implication [template]:** Social site count is a generator parameter (minimum: 4). The template needs named social site types and their relationship requirements (which types must be adjacent, which must be separated). + +--- + +#### T-05 — Transition Corridor Crossing Time +**What's at stake:** Ozzie requires 20–25 seconds at Walk stance for the corridor crossing. At Walk (1 tile / 2 ticks, 10 tps = 5 tiles/sec), 20–25 seconds = 100–125 visual tiles. The transition corridor in the smuggling layout is ~40m = 40 visual tiles, achieving only ~8 seconds. + +**Options:** +- **Option A — Extend the corridor.** Make the physical transition corridor 100–125 tiles. This is very long (100–125m) and may not fit the station profile ("compact district, 800 population"). +- **Option B — Accept 8 seconds.** Adjust Ozzie's target to match the physical reality. 8 seconds at Walk is not nothing — it's still a beat. Ozzie's 20–25s may be aspirational rather than hard. +- **Option C — Add intermediate spaces.** The transition route includes the forecourt (Terminal side) and entry zone (Bar side). If total route = corridor + forecourt + bar entry area, total walking distance may reach 60–80 tiles (~12–16 seconds). Closer to the target without an implausibly long corridor. + +**Generator implication [template]:** Inter-site transit time is a gameplay parameter. The template should specify minimum/maximum transit time between major social sites, not corridor length directly. + +--- + +#### T-06 — Maintenance Spine Route (Era 1 vs. Efficient Path) +**What's at stake:** Miri says the maintenance spine predates the buildings (Era 1 construction). If the spine's route is fixed and buildings were placed around it, the maintenance corridor between Terminal and Bar may not run in the most direct path. But the smuggling layout shows a relatively direct corridor connection. Is the route direct (efficient) or wandering (Era 1 authentic)? + +**The tension:** A wandering Era 1 corridor is setting-authentic but adds tile complexity and may not fit cleanly in the district layout. A direct corridor is simpler to lay out but slightly undermines Miri's historical rationale. + +**Generator implication [template]:** Maintenance spine routing is a generator parameter. The template should specify whether maintenance corridors follow the shortest path or use a historically-layered routing algorithm. + +--- + +#### T-07 — Corridor Width Minimums (Unspecified) +**What's at stake:** Araminta flagged minimum corridor widths by type but did not provide values. Confirmed: maintenance corridor = 2m (2 visual tiles), transition corridor = 6m (6 visual tiles). Unspecified: internal building corridors, secondary public corridors, service alcoves. + +**Round 2 needs:** Explicit minimum widths for each corridor type. These become V-05's specified values and feed into the generator's spatial layout rules. + +--- + +#### T-08 — Gate Cluster Zone Temperature Tint +**What's at stake:** D-059 assigns zone temperature tints to Terminal (cool dark), Bar (warm dark), corridors (neutral dark). The gate cluster is a fourth zone type requiring a tint. Araminta says it's the "coolest and newest" — suggesting a colder tint than the Terminal. But D-059 already uses "cool dark" for the Terminal. The gate cluster needs a distinct value. + +**Options:** Very cool (near-white institutional), clinical blue-white, or a Commission-grey that reads as "newer" than Terminal's grey-navy. + +**Generator implication [template]:** Zone temperature tint is a per-zone-type parameter. The template needs a tint for each of: logistics-hub, social-venue, transit-corridor, entry-gate. Currently only the first three are decided. + +--- + +## §2 — ROUND 2: Cross-Examination and Lead Feedback + +*Compiled by Qatux. Sources: 6 agent Round 2 contributions + lead feedback that resolved T-01 and T-05.* + +--- + +### Lead Feedback (resolved before agents responded) + +**Chunk size direction:** Lead prefers larger chunks with a sub-chunk quarter system. Chunks divide into 4 quarters that can merge into one edifice or remain separate. Large civic structures (train stations, government buildings) span multiple chunks. Generator must be top-down: geography → infrastructure → amenities → population → zoning → chunk generation → individual fill. Separate generator architecture workshop required — this is architectural precedent, not v0.1 configuration. + +**Z-level PoC:** Lead mandates one building in the district with a staircase as z-level proof-of-concept. Gate cluster observation gallery selected by consensus — the only unconfirmed location, setting-authentic, investigation-valuable, clean implementation test case. Overrides V-07's "no z-levels without justification" — the PoC IS the justification. + +**T-01 resolved — Option C:** "Hearing through walls is a flimsy core proposition. We don't build out of cardboard." Sound-through-walls is an exception, not a pattern. Detection toolkit is cameras, drones, bugs, maintenance shafts/vents/tunnels. Path B starts on the cargo floor, not the break room. Terminal layout #311 unchanged. + +**T-05 resolved — Careful stance:** Confirmed. Full route at Careful (3.33 tiles/sec per D-053) ≈ 80 tiles = ~24 seconds. Constraint is stance-dependent, not corridor-length-dependent. No layout change needed. + +**Gate/transport lore clarification (S-02 revision):** System gates serve only freight externally. "Commuter transit" = internal station transit (train/tram) from Residential Core. One external entry (span gate). One internal transit stop within the district. T-03 reframed as T-03b: where does the internal transit stop sit? + +**Transport lore questions:** Captured as Q-040–Q-044. All assigned to Miri. + +--- + +### Agent Round 2 Positions + +**Ozzie (Player Experience)** +- Confirmed T-05 can be satisfied by Careful stance measurement — accepts this resolution +- "Strong yes" on G-11 (gate cluster observation gallery as player investigation vantage point) +- Flagged V-06 exception: Terminal main corridor should be bare of LOS anchors — the exposure is the gameplay mechanic, not a design oversight. Open spaces that are *meant* to feel dangerous are exempt from the anchor rule +- Requested G-08 receive an official name in the D-record (the "nothing looks wrong" principle — mundane face on criminal infrastructure) + +**Gestalt (Systems Design)** +- Gate cluster triangle composition: customs officer + freight forwarder + waiting commuter (3 NPCs, distinct interests, credible spatial conflict) +- 4th social site = **transit hub** (bar-side internal transit stop has its own NPC population, social dynamics, and convergence function). This resolves T-04 — site count: Terminal + Bar + Gate cluster + Transit hub = 4, satisfying G-02 minimum +- G-11 confirmed: observation gallery at z=2 is a valid **investigation vantage point** — qualifies as a gameplay-justified z-level (H-13 / V-07 condition satisfied by lead's PoC directive) + +**Miri (Worldbuilding)** +- [PENDING Round 3 — horizon station revision, T-04 position, Commission overlap resolution] +- Internal transit stop: bar-side position accepted (workers commute to bar district, then walk to Terminal for shift) +- Transport lore model submitted — see Q-040–Q-044 for captured questions + +**Tyre (Technical)** +- [PENDING Round 3 — chunk size technical confirmation at 64×64 visual, z-level validation, cross-z shadowcasting spec] + +**Araminta (Visual/Spatial)** +- **Corridor width minimums (V-05 now specified):** + +| Corridor type | Width (visual tiles) | Width (meters) | +|--------------|---------------------|----------------| +| Maintenance corridor | 2 | 2m | +| Internal building corridors | 2 | 2m | +| Secondary public corridors | 4 | 4m | +| Transition corridor | 6 | 6m | +| Gate customs lanes | 2 | 2m (per lane) | +| Gate concourse | 8 | 8m | +| Service alcoves | 1 | 1m | + +- **Gate cluster zone temperature tint:** `#0a1520` — deep institutional cold, distinct from Terminal's cool-grey-navy. Coldest zone in the district +- **Gate observation gallery spec:** 4 tiles wide × lane-length, Commission grey-white palette. Gallery floor is z=2; same zone tint as gate cluster ground floor (elevation ≠ new zone) +- **Outlier on chunk size:** Araminta prefers 64×64 sim (32×32 visual). Rationale: visual tile is the authoring unit; generator should reason at visual-tile scale. Noted as minority position + +**Paula (Narrative)** +- **Gate cluster triangle revised:** operations manager + senior freight handler + Commission inspector (3 NPCs). Rationale: Commission inspector is Sera Venn's institutional peer — this creates the Commission overlap (N-01) without requiring Sera to be permanently stationed at Terminal. Inspector visits = legitimate, scheduled, observable +- **T-01 revised:** Path B via restricted storage door, not break room wall. The anomalous sound is footsteps on the maintenance grating, heard through the (imperfect) seal around the restricted storage access door on the cargo floor — not sound through a solid wall. The door is the weak point, not the wall. This preserves Option C without invoking cardboard-wall physics +- **Path C confirmed:** A third investigation approach for the detective — the Commission inspection overlap. The Commission inspector (gate cluster triangle NPC) has access to the same manifest anomalies as Maret but reads them as institutional compliance failures, not criminal ones. Detective PC who befriends the inspector gets a different angle on the evidence: institutional rather than human +- **Sector 3:** Supports Miri's framing — sub-area adjacent to maintenance spine, ventilation complaint is ambient NPC dialogue, not a full social site +- **6 narrative constraints for D-record:** Commission inspector has access to gate cluster AND Terminal (inspection authority crosses building boundaries); inspector's Terminal visits are scheduled (visible, predictable — ring can route around them); Sera is a bar regular who knows the inspector professionally (Triangle 4 cross-link); no NPC in the gate cluster triangle has social connection to Voss (Terminal triangle separation maintained); transit hub NPCs are socially isolated from the Terminal workers (two distinct working cultures); back room alley exit connects to maintenance alley which connects to district edge, NOT to the transit hub service area + +--- + +### Round 2 Tension Status + +| Tension | Status after Round 2 | Resolution | +|---------|---------------------|------------| +| T-01 Break room adjacency | **RESOLVED** | Option C: cargo floor + restricted storage door acoustic gap | +| T-02 Gate cluster scope | **RESOLVED** | Two competing triangle compositions (Gestalt vs. Paula) — Round 3 to pick one | +| T-03b Transit stop placement | **RESOLVED** | Bar-side — consensus | +| T-04 4th social site | **RESOLVED** | Transit hub (bar-side) | +| T-05 Corridor crossing time | **RESOLVED** | Careful stance ~24s on ~80-tile route | +| T-06 Maintenance spine routing | **RESOLVED** | Direct path for v0.1; wandering routing deferred to generator workshop | +| T-07 Corridor widths | **RESOLVED** | Araminta's table (see above) | +| T-08 Gate cluster tint | **RESOLVED** | `#0a1520` deep institutional cold | +| NC-01 Transit stop placement | **RESOLVED** | Same as T-03b | +| NC-02 G-10 chunk alignment | **PARTIALLY RESOLVED** | At 64×64 visual chunks, chunk ≈ zone; G-10 revised accordingly | +| NC-03 Gallery tint (z=2) | **RESOLVED** | Same tint as gate cluster ground floor | +| NC-04 Sector 3 / maintenance spine | **RESOLVED** | Adjacent; ventilation = ambient NPC dialogue | + +**Remaining for Round 3:** Gate cluster triangle — pick Gestalt's composition or Paula's. Chunk size — confirm 64×64 visual (Tyre analysis pending). Miri's horizon station revision and Commission overlap resolution. + +--- + +## §3 — ROUND 3: Convergence + +*Compiled by Qatux. All 6 agents delivered Round 3. All tensions resolved. D-records filed: D-093, D-094, D-095.* + +--- + +### Confirmed Consensus + +**T-01 (break room / Path B):** Option C confirmed by lead. Sound anomaly at restricted storage door (cargo floor) → Path B. Paula's refinement accepted: acoustic gap is the door seal, not a wall. No modification to terminal layout #311. + +**T-03b (transit stop):** Bar-side. Unanimous. Creates NPC convergence point at bar entry (satisfies G-03). + +**T-04 (4th social site):** Sector 3 residential (Drin/Naia anchor). Miri's Round 3 revision supersedes the interim Transit Hub consensus. The transit platform (The Loop stop) is reclassified as an encounter node (bar-side convergence point). Site count = 4 (Terminal, Bar, Gate cluster, Sector 3 residential). G-02 minimum satisfied. + +**T-05 (crossing time):** Walk ~13–14s on ~65–70 tile route; Careful stance ~24s. Ozzie confirms. No layout change. + +**T-06 (maintenance spine routing):** Direct path for v0.1. Generator-level concern deferred. + +**T-07 (corridor widths):** Araminta's table confirmed. Filed as V-05 specified values. + +**T-08 (gate cluster tint):** `#0a1222` deep institutional cold (Araminta final). Gallery at z=2 shares ground-floor tint. + +**G-11 (observation gallery as investigation vantage):** Confirmed by Gestalt + Ozzie. The gate cluster observation gallery at z=2 is a designated investigation position — the player can observe arriving cargo from elevation. This is the gameplay justification for the z-level PoC. + +**G-08 naming:** Confirmed name: **"Invisible infrastructure principle"** — every ring location serves a mundane purpose; criminal function is only apparent if you know what to look for. This is the spatial design principle stated in the smuggling layout and now formally named. + +**V-06 exception (main corridor):** The Terminal main corridor is explicitly exempt from the LOS anchor rule. The bare, unobstructed corridor is the gameplay mechanic — exposure IS the design. Araminta confirmed. + +**Gate cluster triangle:** Paula's composition selected — operations manager + senior freight handler + Commission inspector. Rationale: Commission inspector creates the narrative bridge (N-01, Path C, Sera's institutional peer) that Gestalt's commuter-based composition cannot provide. + +**Path C (Commission inspection overlap):** Confirmed. The Commission inspector at the gate cluster has institutional access to Terminal manifest data. Detective PC who builds relationship with inspector gains a third, institutional angle on the evidence. Non-confrontational. Sera Venn cannot access gate cluster customs records without a formal Commission request — separate institutional chains. + +**Sector 3 residential (S-04):** Sub-area adjacent to maintenance spine. 4th confirmed social site (D-025). Anchor NPCs: Drin and Naia (ongoing ventilation dispute). Industrial Sector jurisdiction. Ambient dialogue provides local texture without plot relevance. + +**Commission overlap resolution (N-01):** Gate cluster customs zone is Commission-jurisdictioned space. Commission inspector has scheduled inspection authority crossing into Terminal. This explains Sera's legitimate Terminal presence without requiring her to be stationed there. + +**Chunk size (confirmed):** Chunk = 64×64 sim (32×32 visual, 32m) — streaming unit. Block = 128×128 sim (64×64 visual, 64m) — generator planning unit, 4 chunks. District = 4×4 blocks = 512×512 sim (256×256 visual, 256m). Quarter = 32×32 visual within a block (sub-block unit for generator fill). Araminta dissent (preferred 32×32 visual chunk) noted, overruled. Amends D-012; supersedes D-014 estimate. Filed as D-094. + +**Z-level scheme (confirmed):** z=0 maintenance corridor (Era 1), z=1 all main district structures (Terminal, Bar, Gate cluster ground, transit platform), z=2 Gate cluster observation gallery only. Cross-z LOS: gallery rail = transparent low wall; player on z=2 sees z=1 below; z=1 cannot see upward unless at staircase. Confirmed by Tyre. + +**Transport lore (Miri — confirmed):** Span gates: human-built, single aperture, dual-use windows (freight/passenger). Horizon stations: alien-built, 4–8 apertures, Oort-cloud distance, "The Ring" per-system. Sequential hop travel only. The Loop: 6-district internal tram, 4min Residential Core → Transit District. Station profile correction: Sova's horizon gates at The Ring, not Admin Hub. Q-040/Q-041/Q-043/Q-044 resolved → D-093/D-095. Q-042 partially resolved. Filed as D-095. + +--- + +### Round 3 — All Items Resolved + +All tensions and open items from Rounds 1 and 2 resolved. D-records filed: D-093 (`decisions/content.md`), D-094 (`decisions/architecture.md`), D-095 (`decisions/content.md`). No pending items. + +--- + +## §4 — D-RECORDS FILED + +*Filed by Qatux, 2026-02-25. D-093 (Sova Transit District spatial layout), D-094 (District spatial hierarchy), D-095 (Horizon stations and gate infrastructure). See `decisions/content.md` and `decisions/architecture.md`.* + +--- + +### D-093: Sova Transit District — Spatial Layout and District Topology + +**Decision file:** `decisions/content.md` +**Date:** 2026-02-25 +**Source:** Station District Layout Workshop, Ticket #153 (Sprint 20) +**Raised by:** Full team (Gestalt, Miri, Araminta, Tyre, Paula, Ozzie). Compiled by Qatux. +**Dissent:** Araminta on chunk size (prefers 32×32 visual chunk; overruled by lead and team majority). No other dissent. + +--- + +#### Decision + +The Sova Transit District spatial layout is confirmed as follows. + +--- + +#### 1. District Topology + +``` + N (external — span gate, horizon station connection) + ↑ +┌─────────────────────────────────────────┐ +│ GATE CLUSTER │ ~40×32 visual tiles [T-02 est.] +│ z=1: gate floor, customs lanes (2m ea),│ Tint: #0a1222 (institutional cold) +│ concourse (8m), processing zone │ Access: PUBLIC (concourse) +│ z=2: observation gallery (4 tiles wide)│ SEMI-PRIVATE (customs lanes) +│ Commission grey-white palette │ PRIVATE (inspection booths) +└────────────────┬────────────────────────┘ + │ forecourt (~8–10 tiles deep) +┌────────────────┴────────────────────────┐ +│ TERMINAL (Sova Logistics Hub) │ 44×28 visual tiles (confirmed #311) +│ z=1. Tint: cool grey-navy │ Access: PUBLIC (entry lobby) +│ Entry lobby → scanner bays → │ SEMI-PUBLIC (main corridor) +│ main corridor → cargo floor / │ SEMI-PRIVATE (cargo floor, +│ manifest processing / break room → │ manifest proc., break room) +│ restricted storage (coded, 1 door) │ PRIVATE (supervisor office, +│ Supervisor office: [=] window south │ restricted storage) +└────────────────┬────────────────────────┘ + │ ← maintenance corridor z=0 branches east here + │ (restricted storage → M-HATCH-T → 40m → M-HATCH-B) + │ + TRANSITION CORRIDOR + ~40m × 6m (6 visual tiles wide) Tint: neutral dark + Surveillance camera at T=24m Access: SEMI-PUBLIC + Widening zones at both ends + (forecount N-side, bar entry S-side) + │ + │ ← maintenance corridor z=0 terminates at M-HATCH-B + │ (opens into bar bathroom corridor) +┌────────────────┴────────────────────────┐ +│ TRANSIT PLATFORM (encounter node) │ [dimensions TBD — ~12×8 visual est.] +│ z=1. The Loop tram stop (bar-side) │ Tint: neutral-warm (transitional) +│ (tram from Residential Core) │ Access: PUBLIC +│ NPC population: commuters, workers. │ +│ Convergence point #2 (G-03). │ +└────────────────┬────────────────────────┘ + │ (bar entry zone, ~5 tiles) +┌────────────────┴────────────────────────┐ +│ BAR — THE LAST SHIFT │ 28×22 + 6m east extension visual (confirmed #312) +│ z=1. Tint: warm dark amber │ Access: PUBLIC (main floor, card table) +│ East extension (bathroom corridor) │ SEMI-PRIVATE (serving south, +│ faces north toward transit hub. │ bathroom corridor) +│ Alley door (south) → maintenance │ PRIVATE (back room) +│ alley → district edge. │ +└─────────────────────────────────────────┘ + ↓ + S (maintenance alley — district edge) + +───────────────────────────────────────────────────────────────────── + +MAINTENANCE CORRIDOR (z=0, runs parallel to transition corridor) + + [RESTRICTED STORAGE north wall / cargo floor door] + → M-HATCH-T (locked hatch, restricted storage interior) + → EAST SERVICE SPINE (~15m, narrow, dim) + → JUNCTION-1 (DD-2 dead-drop location) + → [side branch west] SECTOR 3 RESIDENTIAL (~15×12 visual) + Drin/Naia (ventilation dispute anchor) + 4th social site (D-025). Access: PUBLIC + → DISTRICT SPINE (~25m, grating floor) + → DD-3 "The Mark" (go/no-go signal, mid-corridor) + → M-HATCH-B (locked hatch, bar bathroom corridor) + +Era 1 construction. No Meridian coverage. Grating floors. +Zero LOS from all other spaces. Zero regular NPC traffic. +Access: PRIVATE (ring members only in practice). +``` + +--- + +#### 2. Zone Dimensions + +| Zone | Visual tiles | Sim tiles | Notes | +|------|-------------|-----------|-------| +| Gate cluster (z=1) | 40×32 | 80×64 | Araminta confirmed — 7 zones (see §4.2 zone spec) | +| Gate observation gallery (z=2) | 32×10 | 64×20 | Commission grey-white; Araminta confirmed | +| Terminal | 44×28 | 88×56 | Confirmed (#311) | +| Forecourt (Terminal N-face) | ~44×10 | ~88×20 | Estimate | +| Transition corridor | ~40×6 | ~80×12 | Confirmed (#313) | +| Transit platform | ~12×8 | ~24×16 | Encounter node — The Loop stop; bar-side | +| Sector 3 residential | ~15×12 | ~30×24 | 4th social site (D-025); maintenance spine adjacent | +| Bar entry zone | ~28×5 | ~56×10 | Estimate | +| Bar (Last Shift) | 28×22 (+6m E ext.) | 56×44 (+12 E ext.) | Confirmed (#312) | +| Maintenance corridor | ~40×2 | ~80×4 | Confirmed (#313) | +| **Total district bounding box** | **~256×256 visual** | **~512×512 sim** | 4×4 blocks per D-094; D-014 estimate superseded | + +--- + +#### 3. Access Topology + +Sequential. No tier skipping (H-06). + +``` +PUBLIC → SEMI-PUBLIC → SEMI-PRIVATE → PRIVATE +────── ─────────── ──────────── ─────── +Gate concourse Transition corridor Terminal cargo floor Supervisor office +Gate floor Terminal main corridor Terminal manifest proc. Restricted storage +Terminal lobby Gate customs entry Terminal break room Maintenance corridor +Transit hub Bar serving south Bar back room +Bar main floor Bar bathroom corridor +Bar card table +Bar counter (cust. side) +``` + +Movement across tiers requires: worker role (trivial), authority (badge → semi-private), access code (private), ring membership (maintenance corridor/restricted storage). + +--- + +#### 4. Spatial Hierarchy (Confirmed Naming) + +- **Chunk** = 64×64 sim (32×32 visual, 32m) — streaming and serialization unit +- **Block** = 128×128 sim (64×64 visual, 64m) — generator planning unit; composed of 2×2 chunks (4 chunks per block) +- **District** = 4×4 blocks = 512×512 sim (256×256 visual, 256m); 16 blocks, 64 chunks per z-level +- **Chunk merge rules:** Adjacent chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes, shacks), or form L-shaped buildings across chunk boundaries +- **Large civic structures:** Span multiple blocks (gate cluster, horizon station installations, stadiums, parks) +- **District skeleton:** Each major social site occupies approximately one chunk (32×32 visual) within its block + +**Generator architecture note:** The full top-down generator model (geography → infrastructure → amenities → population → zoning → block generation → chunk fill) requires a dedicated workshop brief. This chunk/block/district specification is the spatial primitive for that future system. Q-036 tracks district skeleton design. + +--- + +#### 5. Z-Level Scheme + +| Level | Contents | Notes | +|-------|----------|-------| +| z=0 | Maintenance corridor (Era 1) | Full district. Dim cold-white lighting. Grating floors. No Meridian. | +| z=1 | All main district structures | Terminal, Bar, Gate cluster ground, transit platform, Transition corridor | +| z=2 | Gate cluster observation gallery only | 4 tiles wide × lane-length. Commission grey-white palette. Access via staircase in gate cluster. | + +**Cross-z LOS (confirmed, Tyre):** Vertical LOS propagates only through designated transparent floor/window tiles. Opaque floor tile = full LOS block. Gallery rail = transparent floor tile, designer-placed. Player on z=2 has LOS downward through transparent rail to gate floor (z=1); LOS does not propagate upward from z=1 except at staircase opening. Performance cost: ~100–150µs per additional FOV pass — trivial. Gallery observation capability is architecturally controlled by designer tile placement, not a special case. + +**Inter-z sound (confirmed, Tyre):** Sound propagates across z-levels only through open hatches and designated vent tiles. The maintenance corridor (z=0) is inaudible from z=1 except at hatch locations (M-HATCH-T inside restricted storage, M-HATCH-B in bar bathroom corridor). This makes the acoustic gap at the cargo floor door (Path B) mechanically coherent — it is a z=1 surface feature, not a z=0 leak. + +**Memory (confirmed, Tyre):** Three z-levels for this district = ~1.35MB. Trivial. + +**Z-level PoC purpose:** The gate cluster observation gallery proves the z-level rendering stack (D-049) and cross-z shadowcasting (D-035) for all future development. This is the only z=2 space in v0.1. + +--- + +#### 6. Key Sightline Relationships + +*Across the full district:* + +| From | To | LOS | Notes | +|------|----|-----|-------| +| Gate observation gallery (z=2) | Gate floor below (z=1) | ✓ downward | Player sees cargo off-loading from above | +| Transition corridor (any position) | Terminal exterior / Bar exterior | ✗ | Buildings are opaque from outside | +| Transit hub | Bar entry zone | ✓ | Convergence point — workers arriving see bar entrance | +| Maintenance corridor | Anywhere | ✗ | Zero LOS in or out. Sound only. | +| Maintenance hatch (M-HATCH-T) | Cargo floor | ✗ | Hatch opens inside restricted storage — only visible to someone already in restricted storage | + +*Within Terminal (from #311 — unchanged):* +- Main corridor → all four south-facing doors: ✓ +- Supervisor window [=] → cargo floor + restricted storage door: ✓ +- Break room → anything: ✗ (isolated) +- Manifest processing → main corridor (door open): ✓ + +*Within Bar (from #312 — unchanged):* +- Corner booth (NW deepest) → entrance, bar counter, card table, back room door: ✓ +- Bathroom corridor interior → main bar: ✗ (door only) + +--- + +#### 7. NPC Routes and Convergence Points + +Two confirmed convergence points outside Terminal and Bar (satisfies G-03): + +1. **Terminal main corridor** — all Terminal workers cross here. Every person moving between scanner bays (north) and cargo floor / manifest processing / supervisor office (south) passes through. Semi-public; lingering is suspicious. + +2. **Transit platform / bar entry zone** — workers arriving from Residential Core via The Loop tram. Some walk north to Terminal, some enter bar directly. NPCs from different social sites share this arrival space. Encounter node, not social site. + +Additional convergence: **Transition corridor** — ring members and workers both use this corridor. The surveillance camera at T=24m records all transits. Pattern analysis reveals ring operational schedule. + +--- + +#### 8. Investigation Paths (confirmed) + +**Path A — Pattern Recognition (insert-heavy):** +Corridor camera logs → Kael's deep-night transit pattern → manifest database access (manifest processing) → restricted storage access timing → ring identified. + +**Path B — Physical Traversal (exploration-heavy):** +Cargo floor: anomalous sound at restricted storage door (acoustic gap around coded door seal) → floor worker conversation → physical discovery of maintenance hatch → corridor traversal → dead-drops discovered. *Note: sound propagation through door seal, not wall. Door is the weak point.* + +**Path C — Commission Inspection Overlap (institutional):** +Detective builds relationship with Commission inspector (gate cluster triangle NPC) → inspector shares institutional read of manifest anomalies (compliance framing, not criminal) → detective gains third angle on same evidence. Non-confrontational. Complements Paths A and B. + +--- + +#### 9. Transport Lore + +*Captured from workshop discussion:* + +**Span gates (Q-040 — resolved → D-093):** Human-built. Single aperture. Near-instantaneous transit. Scheduled dual-use windows: freight (bulk of operating hours) and passenger (scheduled slots). Physical layout reflected in gate cluster zone spec (§4.2). + +**Horizon stations (Q-041 — resolved → D-095):** Alien-built (no identified builder species). Self-maintaining. 4–8 apertures per station. Located at Oort-cloud distance. Per-system canonical name: "The Ring." Sequential hop travel only (A→B→C through intermediate systems; no direct long-range transit). Per-system variation across 4 access tiers. Station Sova's horizon gates are at The Ring — Admin Hub contains booking offices only. + +**Intra-system transport (Q-042 — partially resolved):** Span gates at star/planetary level plus horizon stations at Oort distance. Details of intra-system hab-to-hab transit remain open. + +**Station internal transit (Q-043 — resolved → D-095):** "The Loop" — internal station tram, 6 districts, 4-minute Residential Core → Transit District run. Workers arrive at transit platform (bar-side) and disperse to Terminal or bar. + +**Gate-train integration (Q-044 — resolved → D-093/D-095):** Arriving passengers exit span gate → aperture chamber → freight/passenger customs lanes → gate concourse (public) → transition corridor → transit platform (The Loop). No direct gate-to-tram connection; transition corridor is the linking space. + +--- + +#### 10. Gate Cluster Social Triangle + +**Composition:** Operations manager + senior freight handler + Commission inspector + +**Spatial staging:** +- Operations manager: works the gate floor (supervises cargo processing, knows every shipment) +- Senior freight handler: works customs lanes (clears cargo for transit; knows what should and shouldn't be there) +- Commission inspector: scheduled inspection visits (legitimate authority, reads anomalies institutionally) + +**Triangle tension:** Operations manager and freight handler have an established working relationship — and a shared interest in not attracting Commission attention. Commission inspector is an outside force disrupting their equilibrium. Investigation opportunity: the inspector sees things the manager and handler want invisible. + +**Commission overlap (N-01, Path C):** Inspector has institutional access to Terminal manifest data. Sera Venn's professional peer. Detective who builds relationship with inspector gains Path C. + +--- + +#### 11. Invisible Infrastructure Principle (G-08, now named) + +Every ring location serves a mundane purpose. Criminal function is only apparent if you know what to look for. The player (smuggler PC) knows. The detective PC can learn. An NPC observing in isolation sees nothing unusual. Same physical space — different player understanding. + +This is the core spatial design principle for the district. All investigation paths are designed around it. The principle is generalisable: it applies to any district in the Reach that hosts a Tier 1 module. + +--- + +#### 12. Narrative Constraints (Paula — all confirmed) + +1. Commission inspector has inspection authority crossing gate cluster AND Terminal — schedules are publicly known +2. Inspector's Terminal visits are scheduled and predictable — ring can route around them; gives ring temporal structure +3. Sera Venn is a bar regular who knows the inspector professionally — Triangle 4 cross-link +4. No NPC in the gate cluster triangle has social connection to Voss — Terminal and gate cluster triangle separation maintained +5. Transit hub NPCs are socially isolated from Terminal workers — two distinct working cultures; they share the transition corridor but not social space +6. Back room alley exit connects to maintenance alley → district edge; does NOT connect to transit hub service area + +--- + +#### Rationale + +The district layout emerged from three rounds of cross-domain synthesis: +- Round 1 established 52 constraints across 6 domains and 8 open tensions +- Round 2 resolved 10 of 11 tensions; lead feedback resolved T-01 (cargo floor) and T-05 (Careful stance) +- Round 3 achieved consensus on all remaining items + +The layout satisfies: D-025 (social sites as atomic template units), D-036 (Sova Transit District setting), D-054 (tile-based movement), D-059 (fog zone temperature tints), D-066 (dual-scale grid), D-011 (fog of perception), D-018 (sound model), D-027 (vertical slice success criteria). + +The chunk/quarter system and top-down generator model establish architectural precedent for Q-036 (district skeleton as generator output). + +--- + +#### Cross-References + +- Terminal layout: `docs/design/spatial-layout-terminal-v01.md` (#311) +- Bar layout: `docs/design/spatial-layout-bar-v01.md` (#312) +- Smuggling corridors: `docs/design/spatial-layout-smuggling-corridors-v01.md` (#313) +- Station profile: `docs/design/sova-station-profile.md` (#320) +- Gate cluster layout: `docs/design/spatial-layout-gate-v01.md` (to be authored — blocks #157) +- Transit hub layout: TBD (new ticket required) +- Transport lore open questions: Q-040–Q-044 +- Generator architecture workshop: pending brief + +--- + +*D-records filed: D-093 (decisions/content.md), D-094 (decisions/architecture.md), D-095 (decisions/content.md). Gate cluster full layout to be authored as #157. District bounding box (256×256 visual) supersedes D-014 estimate per D-094.* diff --git a/docs/sprints/sprint-19/ci.md b/docs/sprints/sprint-19/ci.md new file mode 100644 index 000000000..0c9e79a9f --- /dev/null +++ b/docs/sprints/sprint-19/ci.md @@ -0,0 +1,122 @@ +# Sprint 19: Persist — CI Tasks + +**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening. + +**Branch:** `ci` +**Agents:** Hoshe (QA/CI), Oscar (networking) + +## Carry-over from Sprint 18 + +None. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #270 | Test runner bash scripts | — | +| #556 | Protocol version handshake: client | #555 (server) | +| #342 | IPC round-trip timing benchmark | #555, #556 | +| #271 | IPC serialization fixture files | #270 | + +Use `db/connectors/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack codec, SimBridge trait), D-030 (three-layer test architecture: fixture / mock-protocol / real-subprocess) + +## Notes + +### #270 — Test runner bash scripts + +The test infrastructure has no standardized entry points for CI or agents to invoke. This ticket ships the runner layer. + +What this ticket must deliver: +Six scripts at `tests/`: +1. `tests/run-rust` — runs `cargo test` in `server/`, exits 0/non-zero, JSON stdout summary +2. `tests/run-godot` — runs GUT headlessly (`godot --headless -s client/tests/run_gut.gd`), exits 0/non-zero +3. `tests/run-ipc-fixtures` — Layer 1: reads fixture files from `tests/fixtures/`, validates via Rust + GDScript, exits 0/non-zero +4. `tests/run-ipc-protocol` — Layer 2: runs mock subprocess protocol state machine tests +5. `tests/run-ipc-integration` — Layer 3: starts real server subprocess, runs full round-trip, kills it +6. `tests/run-all` — invokes all five in order, collects exit codes, reports JSON summary + +Script requirements per ticket description: exit code 0/non-zero, structured JSON stdout, accepts filter arguments (`--filter test_name`), no interactive input, whitelistable for Claude Code agents (no TTY prompts). + +JSON stdout format (consistent across all scripts): +```json +{"suite": "rust", "total": 42, "passed": 42, "failed": 0, "duration_ms": 1230} +``` + +These scripts are the entry points that `make ci-server`, `make ci-client`, and future CI pipelines call. Coordinate with Makefile targets in `docs/DEVOPS.md`. + +### #556 — Protocol version handshake: client + +Blocked by #555 (server must send `HandshakeMessage` first). + +What this ticket must deliver: +- `client/scripts/protocol/local_bridge.gd` (or `server_process.gd`): after starting the server subprocess, read the first framed message from the IPC channel +- Validate it is a `HandshakeMessage` with `protocol_version == Protocol.PROTOCOL_VERSION` (14) +- If mismatch: log error "Protocol version mismatch: server=%d, client=%d", emit a `handshake_failed` signal, shut down the server process gracefully +- If match: emit `handshake_complete`, begin normal tick loop +- Add a timeout: if no handshake message received within 5 seconds of process start, treat as mismatch + +Current state: `client/scripts/protocol/protocol.gd` already checks `version` in `decode_snapshot()` and logs a mismatch. That check is per-snapshot. The handshake is the startup-time equivalent — validate once at connection, not per tick. + +Files: `client/scripts/protocol/local_bridge.gd`, `client/scripts/protocol/server_process.gd`. + +### #342 — IPC round-trip timing benchmark + +Sprint exit criterion. Measures the complete latency path from server serialization to client scene update. + +What this ticket must deliver: +- A benchmark script `tests/run-ipc-benchmark` that: + 1. Starts the server subprocess + 2. Waits for handshake (#555/#556) + 3. Sends N `PlayerInput` messages (N = 100 by default) + 4. Measures from `rmp_serde::to_vec` (server) to scene update completion (client) + 5. Reports p50/p95/p99 latencies in milliseconds + 6. Flags if any percentile exceeds 5ms threshold +- Output JSON: `{"p50_ms": 1.2, "p95_ms": 2.8, "p99_ms": 4.1, "threshold_ms": 5, "passed": true}` +- The benchmark is run as part of `tests/run-ipc-integration` in Layer 3 + +Implementation approach: server-side timestamps in `ObserverSnapshot` (add `server_emit_tick_ms` field, stripped in production builds), client records receive timestamp via `Time.get_ticks_msec()`. Delta = client receive - server emit. + +Blocked by #555 and #556 — benchmark requires a working handshake before timing can start cleanly. + +### #271 — IPC serialization fixture files + +Layer 1 test data: pre-generated `.msgpack` fixture files that both Rust and GDScript can read to verify cross-language serialization compatibility. + +What this ticket must deliver: +- A Rust binary (or test in `server/src/`) that generates fixtures to `tests/fixtures/`: + - `snapshot_minimal.msgpack` — minimal valid `ObserverSnapshot` (version=14, tick=0, one entity) + - `snapshot_full.msgpack` — all optional fields populated (monologue, dialogue, inventory, POIs, KG dump) + - `player_input_move.msgpack` — `PlayerInput { tick: 1, action: MoveNorth }` + - `player_input_interact.msgpack` — `PlayerInput { tick: 2, action: Interact { target: 99, verb: "Talk" } }` + - `malformed.msgpack` — intentionally truncated bytes (tests error handling) +- A GDScript test `client/tests/test_ipc_fixtures.gd` that reads each `.msgpack` fixture file, decodes via `Protocol.decode_snapshot()` / `Protocol.decode_player_input()`, and asserts expected field values +- Cross-language verification: the same byte stream decoded by both Rust and GDScript must produce identical field values + +The fixture generator is run once (manually or in CI pre-step) to produce the committed `.msgpack` files. The files live at `tests/fixtures/` and are committed to the repo. + +Blocked by #270 — fixture tests are invoked by `tests/run-ipc-fixtures`. + +## Dependency Chain + +``` +#555 (server: protocol handshake) → #556 (ci: protocol handshake: client) + #555 + #556 → #342 (IPC benchmark: requires working handshake) + +#270 (test runner scripts) → #271 (fixture files: invoked by run-ipc-fixtures) + +Parallel starts: #270, #555 (server-side) — both unblocked week 1 +#556 starts after #555 is at review +#271 starts after #270 merges +#342 starts after #555 + #556 both land +``` + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section): +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(ci): description" --description "body" --base main --head ci +``` diff --git a/docs/sprints/sprint-19/client.md b/docs/sprints/sprint-19/client.md new file mode 100644 index 000000000..ad7bb34f4 --- /dev/null +++ b/docs/sprints/sprint-19/client.md @@ -0,0 +1,128 @@ +# Sprint 19: Persist — Client Tasks + +**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening. + +**Branch:** `client` +**Agents:** Stig (UI), Oscar (networking) + +## Carry-over from Sprint 18 + +None. Sprint 18 closed clean. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #554 | Save/load: client UI | #553 (server) | +| #258 | Game session management | — | +| #205 | GDScript test framework setup | — | +| #206 | Scene testing utilities | #205 | +| #348 | Debug visualization overlay | — | + +Use `db/connectors/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/architecture.md` — D-020 (IPC architecture, MessagePack), D-085 (per-game save directory structure) +- `decisions/questions.md` — Q-029 (save file format design — open, Sprint 19 uses MessagePack quick-and-dirty format) + +## Open Questions to Resolve Early + +- **Q-029: Save file format design** — Sprint 19 ships MessagePack quick-and-dirty format. Do not over-engineer the loading screen metadata. A readable directory name (`<timestamp>-<seed>/`) per D-085 is sufficient for v0.1. The full versioning/migration design is tracked in Q-029 for a later sprint. + +## Notes + +### #554 — Save/load: client UI + +Blocked by #553 (server must implement `SaveCommand`/`LoadCommand` IPC messages before client can wire F5/F6). + +What this ticket must deliver: +- F5 key mapped in `client/scripts/autoloads/input_mapper.gd` to send a `SaveGame` IPC action to the server with the active game directory path (`user://saves/<game-id>/quicksave.sav`) +- F6 key mapped to send `LoadGame` IPC action with the same path +- Server responds with `SaveComplete`/`LoadComplete` — client shows a brief HUD notification ("Saved" / "Loading...") +- Loading screen scene: reads `user://saves/` directory, lists subdirectories sorted by last-modified (most recent first), shows most recent save filename per game directory per D-085 +- F6 from the main menu opens the loading screen +- The active `game-id` is tracked in `GameState` autoload (add `current_game_id: String`) + +Integration points: `client/scripts/autoloads/input_mapper.gd` (key bindings), `client/scripts/autoloads/game_state.gd` (current_game_id field), `client/scripts/protocol/` (new IPC message encoding), `client/scripts/ui/` (loading screen scene). + +Save directory path per D-085: `user://saves/<timestamp>-<seed>/` where game-id is created on New Game (#258). F5 quicksave writes to `user://saves/<game-id>/quicksave.sav`. Loading screen lists directories sorted by `FileAccess.get_modified_time()`. + +Wireframe reference: `docs/design/wireframes/menus/v01-save-load.png`. + +### #258 — Game session management + +New Game creates the per-game save directory before any save occurs (D-085 requirement: "directory created on New Game — even before the first save, so the path exists for quicksave/autosave"). + +What this ticket must deliver: +- `GameState.current_game_id: String` — format `<timestamp>-<seed>` (e.g. `20260225-143022-a7b3f1`) +- On "New Game": generate game-id (timestamp + RNG hex suffix), create `user://saves/<game-id>/` directory via `DirAccess.make_dir_recursive()` +- On "Continue" / loading screen selection: set `current_game_id` from the selected directory name +- "Quit to menu" flow: prompt "Save before quitting?" — F5 save if confirmed +- Wire the game-id into the `SimBridge` startup: server subprocess launched with `--game-id <id>` argument (or equivalent) so server can log with the same ID + +Integration points: `client/scripts/autoloads/game_state.gd` (new fields), `client/scripts/protocol/server_process.gd` (subprocess launch args), `client/scripts/ui/` (main menu scene: New Game / Continue buttons). + +Note: `game_state.gd` is already the largest autoload with 300+ lines. Keep game session logic in a thin wrapper on `GameState` — do not add another 100-line block directly. Consider a `session_manager.gd` helper if the logic exceeds 40 lines. + +### #205 — GDScript test framework setup + +The project has no GDScript test infrastructure yet. The Godot client has no equivalent of `cargo test`. + +What this ticket must deliver: +- Install and configure **GUT (Godot Unit Test)** as the GDScript test framework — it has the best Godot 4 support and is actively maintained +- Create `client/tests/` as the test root directory +- `client/tests/run_gut.gd`: the GUT runner script that CI can invoke headlessly (`godot --headless -s client/tests/run_gut.gd`) +- Exit code 0 = all pass, non-zero = failures — required for CI integration (#270 test runner scripts) +- A single smoke test `client/tests/test_protocol.gd`: verifies `Protocol.decode_snapshot(bytes)` returns non-null for a minimal valid msgpack fixture + +GUT installation: add as a Godot addon. Check if there is already an `addons/` directory in `client/`. + +### #206 — Scene testing utilities + +Blocked by #205 (GUT must be installed first). + +What this ticket must deliver: +- `client/tests/util/scene_helper.gd`: loads a scene file by path, instantiates it into a temporary viewport, provides `assert_node_exists(path)`, `assert_signal_emitted(node, signal_name)`, and `get_node_at(path)` helpers +- `client/tests/test_game_state.gd`: tests for `GameState.apply_snapshot()` — verify that a snapshot dictionary with known fields updates the correct `GameState` fields +- `client/tests/test_protocol.gd` (extend from #205 smoke test): add roundtrip test for `Protocol.encode_player_input()` and `Protocol.decode_player_input()` + +These utilities are the scaffolding for all future client tests. Keep them minimal and dependency-free — do not require a running server. + +### #348 — Debug visualization overlay + +Dev tool (F3 toggle). The stub `client/scripts/ui/debug_overlay.gd` already exists. + +What this ticket must deliver: +- Extend `debug_overlay.gd` to draw on a `CanvasLayer` above the game world: + - **Pathfinding waypoints**: draw lines between waypoint positions from `GameState.visible_entities` (entities with kind `Npc` — estimate waypoints from position delta between ticks) + - **Line-of-sight rays**: draw lines from player position to each visible entity + - **Vision cone boundary**: draw the forward/peripheral arc boundary using `GameState.visibility_sectors` + - **Information state tags**: draw confidence label (Suspects/KnowsOf/KnowsDetails/Direct) above each visible NPC from `GameState.player_knowledge` + - **Tick timing graph**: small line chart in corner showing tick delta over the last 30 ticks +- F3 toggle: connected to `InputMapper` action `toggle_debug_overlay` +- Debug overlay is **dev-only**: compiled out in export builds via `OS.is_debug_build()` check + +Integration points: `client/scripts/autoloads/game_state.gd` (data source), `client/scripts/autoloads/input_mapper.gd` (F3 action), `client/scripts/ui/debug_overlay.gd` (extend existing stub). + +## Dependency Chain + +``` +#205 (GDScript test framework) → #206 (scene testing utilities) + +#258 (game session management) → #554 (save/load client UI: needs current_game_id) + #553 (server, ECS extraction) → #554 (save/load client UI: needs IPC commands) + +#348 (debug overlay) → standalone, parallel track +``` + +Parallel starts: #258, #205, #348 all unblocked week 1. +#554 starts after #553 (server) reaches review stage and #258 lands. +#206 starts after #205 merges. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section): +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client +``` diff --git a/docs/sprints/sprint-19/joint.md b/docs/sprints/sprint-19/joint.md new file mode 100644 index 000000000..d09c4664f --- /dev/null +++ b/docs/sprints/sprint-19/joint.md @@ -0,0 +1,97 @@ +# Sprint 19: Persist — Joint Briefing + +**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening. + +**Sprint:** 19 +**Status:** planning → active + +## Pre-Sprint + +Before implementation begins, no schema work is needed — `SaveStateV1` is already defined (#256, done). However, the following IPC protocol additions must be agreed between server and client **before either side implements**: + +| Item | Owner | Needed by | +|------|-------|-----------| +| `HandshakeMessage` wire format | server (#555) | client (#556) | +| `SaveCommand` / `LoadCommand` IPC message variants | server (#553) | client (#554) | +| `SaveComplete` / `LoadComplete` response format | server (#553) | client (#554) | +| Fixture file format and field names | ci (#271) | all teams | + +Server team: define these in `server/src/bridge/types.rs` first (as Rust structs + serde). CI team + client team: implement against the published definitions. Do not start #556 or #554 until #555 and #553 respectively reach review. + +## Team Allocation + +| Team | Tickets | Count | +|------|---------|-------| +| server | #553, #96, #97, #98, #200, #272, #555 | 7 | +| client | #554, #258, #205, #206, #348 | 5 | +| ci | #270, #556, #342, #271 | 4 | + +## Cross-Team Dependencies + +``` +server #555 (handshake: server) + → ci #556 (handshake: client) + → ci #342 (IPC benchmark) + +server #553 (ECS extraction) + → client #554 (save/load UI) + +server #200 (test module org) + → server #272 (info boundary tests) + +client #205 (GDScript test framework) + → client #206 (scene testing utilities) + → ci #271 (fixture files need GDScript reader) + +ci #270 (test runner scripts) + → ci #271 (fixture tests invoked by run-ipc-fixtures) +``` + +## Sprint Completion Proof + +When Sprint 19 is done, the following must all be observable: + +1. **Save/load round-trip**: Press F5 in-game → file appears at `user://saves/<game-id>/quicksave.sav` in MessagePack format. Press F6 → game state restored from file (tick, entities, player knowledge match pre-save state). + +2. **Per-game directory**: Starting a New Game creates `user://saves/<timestamp>-<seed>/` before any save occurs. The loading screen lists this directory. + +3. **Tier eviction**: Spawn 90+ NPCs (above Active cap of 80). `ActiveSim` count stabilizes at ≤80 with the excess evicted to `BackgroundSim`/`StateSaved`. Scope-tagged NPCs (KnownContact, Colleague) remain Active regardless. + +4. **Protocol handshake**: Starting the server subprocess: first IPC message is a `HandshakeMessage`. Version mismatch (force by temporarily changing server `PROTOCOL_VERSION`) produces an error and clean shutdown — no crash. + +5. **Test infrastructure**: `tests/run-all` exits 0 with all suites passing. `tests/run-ipc-fixtures` reads committed `.msgpack` files and validates both Rust and GDScript decode them identically. `cargo test` in `server/` includes information boundary negative tests that assert absence of leakage. + +6. **Debug overlay**: F3 in-game toggles the debug canvas showing vision cone arcs, entity LOS rays, NPC knowledge confidence labels, and tick timing graph. + +## Test Plan (D-030) + +| Layer | Runner | Tickets | When | +|-------|--------|---------|------| +| Layer 1: Fixture serialization | `tests/run-ipc-fixtures` | #271, #200 | Every edit | +| Layer 1: Unit tests (Rust) | `tests/run-rust` | #272, #96, #97, #98 | Every edit | +| Layer 1: Unit tests (GDScript) | `tests/run-godot` | #205, #206 | Every edit | +| Layer 2: Mock protocol | `tests/run-ipc-protocol` | #555, #556 | Every PR | +| Layer 3: Real subprocess | `tests/run-ipc-integration` | #342, #553/#554 | Daily/pre-merge | + +All layers must pass before any PR merges. `make ci` invokes `tests/run-all`. + +## Key Decisions Reference + +| Decision | Domain file | Relevant to | +|----------|------------|-------------| +| D-010: Determinism + info boundaries | architecture.md | #272, #96, #553 | +| D-020: IPC architecture, MessagePack | architecture.md | #553, #554, #555, #556, #342, #271 | +| D-026: Simulation tiers, timestamp eviction, scope tags | architecture.md | #96, #97, #98 | +| D-030: Three-layer test architecture | architecture.md | #200, #270, #271, #272, #342 | +| D-085: Per-game save directory structure | architecture.md | #554, #258, #553 | +| Q-029: Save file format (open) | questions.md | #553 (quick-and-dirty MessagePack for now) | + +## Risk Register + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| ECS extraction misses components (#553) | Medium | High | #272 info boundary tests catch leakage; fixture roundtrip (#271) catches missing fields | +| IPC protocol mismatch between #555 and #556 | Low | High | Define wire types in Rust first, share definition doc before client implements | +| GUT framework incompatible with Godot 4.x version in use (#205) | Low | Medium | Verify GUT version before full installation; fallback to hand-rolled test runner | +| Save file bloat (SaveStateV1 larger than ~1-2 KB/NPC) | Low | Low | Q-029 tracks compression — deferred. Profile with #342 benchmark if flagged | +| Scope tag assignment races with eviction (#97/#98) | Low | Medium | Eviction runs after scope tag system in schedule order; schedule ordering test in #97 | diff --git a/docs/sprints/sprint-19/server.md b/docs/sprints/sprint-19/server.md new file mode 100644 index 000000000..3470cd932 --- /dev/null +++ b/docs/sprints/sprint-19/server.md @@ -0,0 +1,152 @@ +# Sprint 19: Persist — Server Tasks + +**Goal:** The player can save and resume a game session with per-game directories; the simulation tier system gains eviction and scope pinning; and the first test infrastructure ships with information boundary validation and IPC hardening. + +**Branch:** `server` +**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA) + +## Carry-over from Sprint 18 + +None. Sprint 18 closed clean. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #553 | Save/load: server ECS extraction | #256 (done) | +| #96 | State serialization system | — | +| #97 | Timestamp-based eviction | — | +| #98 | Scope tag system | — | +| #200 | Test module organization | — | +| #272 | Information boundary negative test suite | #200 | +| #555 | Protocol version handshake: server | — | + +Use `db/connectors/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/architecture.md` — D-010 (determinism + info boundaries), D-020 (IPC architecture, MessagePack), D-026 (simulation tiers: Active/Background/State-saved/Ungenerated, timestamp eviction, scope tags), D-030 (three-layer test architecture), D-041 (knowledge graph data model, StableId) +- `decisions/questions.md` — Q-029 (save file format design — Sprint 19 ships quick-and-dirty, Q-029 tracks the thorough design pass for later) + +## Open Questions to Resolve Early + +- **Q-029: Save file format design** — Sprint 19 uses MessagePack from `SaveStateV1`. Resolution of full versioning/migration strategy is deferred. Do not block #553 on Q-029; proceed with MessagePack format as specified. + +## Notes + +### #553 — Save/load: server ECS extraction + +`server/src/simulation/save_state.rs` already defines `SaveStateV1` (done in #256). The data model is complete: tick, seed, RNG, `player_knowledge: KnowledgeGraph`, `relationship_graph: RelationshipGraph`, `npc_states: Vec<NpcSaveState>`. Roundtrip tests pass. + +What this ticket must deliver: +- A `save_to_file(path: &Path, world: &World) -> Result<()>` function: queries ECS for all relevant components, builds a `SaveStateV1`, calls `state.to_bytes()`, writes to disk. Per-game directory path is provided by the client via a new `IpcCommand::SaveGame { path: String }` variant. +- A `load_from_file(path: &Path, world: &mut World) -> Result<()>` function: reads bytes, calls `SaveStateV1::from_bytes`, re-spawns entities, injects `KnowledgeGraph`, `RelationshipGraph`, and `SimulationTime` as resources, reseeds the RNG. +- A `SaveCommand` and `LoadCommand` IPC message pair wired through `server/src/bridge/` — server receives save/load triggers from the client, executes, sends `SaveComplete`/`LoadComplete` response. +- Format version check on load: reject files with `format_version != SAVE_FORMAT_VERSION` with a clear error. + +Integration points: `server/src/simulation/save_state.rs` (data model), `server/src/bridge/types.rs` (new IPC commands), `server/src/bridge/local.rs` or `tcp.rs` (command dispatch), `server/src/knowledge/graph.rs` (KG re-injection), `server/src/npc/relationships.rs` (RelationshipGraph re-injection). + +Gotcha: ECS entity IDs are generational — do not save bevy `Entity` handles. `SaveStateV1` already uses `StableId(u64)` throughout. On load, re-spawn entities and re-register `StableId -> Entity` in `EntityRegistry`. + +### #96 — State serialization system + +Complement to #553. Where #553 handles whole-game ECS extraction, #96 implements the per-NPC serialization primitive for tier transitions. + +What this ticket must deliver: +- A `serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState` function producing the frozen struct (~1-2 KB per NPC per D-026) +- A `deserialize_npc_from_frozen(state: &NpcSaveState, commands: &mut Commands)` that re-spawns a full NPC entity with the correct component set +- Used by the tier system when evicting to `StateSaved`: instead of keeping ECS components live, serialize to `NpcSaveState` and despawn. On reactivation: deserialize and re-spawn. +- Unit tests: serialize + deserialize produces an entity with identical component values + +Existing shape: `NpcSaveState` in `save_state.rs` captures position, `SecretSeverity`, `Relationships`, stress, tolerance, contentment, and optional `KnowledgeGraph`. Verify this covers all components needed for Background/Active reconstruction. Flag any missing axis (D-024) in a code comment for follow-up. + +### #97 — Timestamp-based eviction + +`server/src/simulation/tier.rs` has the tier marker components (`ActiveSim`, `BackgroundSim`, `StateSaved`) and the distance-based `update_tier_markers` system. What is missing: the LRU eviction when sim-space fills up. + +What this ticket must deliver: +- A `LastInteractionTick(u64)` component on all NPCs, updated whenever the player interacts with or observes an NPC +- A `SimSpacePressure` resource tracking current `ActiveSim` count vs. capacity (cap: 80 per D-026) +- An `evict_excess_active` system: when `ActiveSim` count exceeds capacity, demote the N oldest-by-`LastInteractionTick` entities to `BackgroundSim` (or `StateSaved` if beyond background radius) +- Uses a priority queue (BinaryHeap keyed by `LastInteractionTick`) for O(log N) eviction selection + +Gotcha: eviction must not demote entities with active scope tags (see #98). The eviction system runs after #98's `ScopeTag` check. + +### #98 — Scope tag system + +Scope tags are the mechanism by which certain NPCs stay pinned to `ActiveSim` regardless of distance or LRU pressure (D-026: "neighborhood, active-quest, colleague, known-contact"). + +What this ticket must deliver: +- A `ScopeTag` component (or enum-tagged component) with variants: `Neighborhood`, `ActiveQuest`, `Colleague`, `KnownContact` +- A `ScopePinned` marker component: attached to any NPC carrying a `ScopeTag`, removed when no scope tags remain +- The eviction system (#97) skips entities with `ScopePinned` +- Scope tags are assigned by gameplay systems: `Neighborhood` from proximity at session start, `KnownContact` from `KnowledgeGraph` entries with confidence >= `KnowsOf`, `Colleague` from `RelationshipGraph` edges with `Friend` or `Colleague` kind, `ActiveQuest` reserved for future quest system + +Integration: `server/src/simulation/tier.rs` (eviction exclusion), `server/src/knowledge/graph.rs` (KnownContact assignment trigger), `server/src/npc/relationships.rs` (Colleague assignment trigger). + +### #200 — Test module organization + +`server/src/test_world/` already exists with `constants.rs`, `invariants.rs`, `mod.rs`, `reset.rs`, and `rooms/`. This is the foundation. + +What this ticket must deliver: +- Establish the external test module pattern for the server crate: `#[cfg(test)] mod tests` in each module, plus a top-level `tests/` directory alongside `src/` for integration tests that run against the full simulation +- Document the three-layer test architecture (D-030): Layer 1 = fixture-based serialization (fast), Layer 2 = mock subprocess protocol state machine (medium), Layer 3 = real subprocess integration (slow) +- Create `tests/integration/mod.rs` as the entry point for Layer 3 tests +- Ensure `cargo test` in `server/` runs all layers correctly +- No-ops are fine for Layer 2 and 3 stubs — the important deliverable is the directory structure and entry points + +#272 is blocked by this ticket — the information boundary tests land in the new structure. + +### #272 — Information boundary negative test suite + +The core asymmetric information claim of the game: entity X cannot see what entity Y knows, unless the observation system explicitly grants it. + +What this ticket must deliver: +- A suite of negative tests asserting that information does NOT cross boundaries: + 1. Player's `KnowledgeGraph` does not contain NPC data that was not observed (no passive leakage) + 2. `ObserverSnapshot` for the player does not include entities outside LOS (fog of perception holds) + 3. Background-tier NPC `KnowledgeGraph` is not updated by Active-tier systems (tier boundary holds) + 4. `SaveStateV1` for one NPC does not serialize another NPC's `KnowledgeGraph` +- Uses `test_world/` for scenario setup — reuse existing helpers +- These tests live in Layer 1 (pure unit) and Layer 2 (mock world) of D-030 + +Gotcha: "negative tests" means asserting absence. Use `assert!(kg.entities.get(&id).is_none())` patterns — not just "test passed because nothing happened." + +### #555 — Protocol version handshake: server + +`server/src/bridge/types.rs` defines `PROTOCOL_VERSION: u8 = 14`. The version is already included in `ObserverSnapshot` as `pub version: u8`. + +What this ticket must deliver: +- Verify the first `ObserverSnapshot` emitted after subprocess startup includes `version: PROTOCOL_VERSION` +- Add a handshake phase: before normal tick loop begins, server emits a minimal `HandshakeMessage { protocol_version: PROTOCOL_VERSION }` as the very first framed message on the IPC channel +- Client reads this message and validates before sending any `PlayerInput` +- If the server receives a `PlayerInput` before completing handshake, log a warning and process normally (forward-compatible) +- Integration point: `server/src/bridge/local.rs` (startup sequence), `server/src/bridge/framing.rs` (message framing) + +Coordinate with ci team (#556) — the client-side validation is their ticket. + +## Dependency Chain + +``` +#555 (protocol handshake: server) → #556 (ci: protocol handshake: client) + +#200 (test module organization) → #272 (information boundary tests) + +#98 (scope tag system) → feeds into #97 (eviction respects scope pins) + +#256 (done: save state data model) → #553 (server ECS extraction) + #553 (server ECS extraction) → #554 (client: save/load UI) + +#96 (state serialization) → feeds into #553 (used during ECS extraction) + +Parallel starts: #555, #97, #98, #96, #200 — all unblocked week 1 +#553 starts after #96 is at review stage (needs serialize_npc_to_frozen) +#272 starts after #200 merges +``` + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section): +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server +``` diff --git a/docs/sprints/sprint-20/client.md b/docs/sprints/sprint-20/client.md new file mode 100644 index 000000000..d575cbf2c --- /dev/null +++ b/docs/sprints/sprint-20/client.md @@ -0,0 +1,115 @@ +# Sprint 20: Shape — Client Tasks + +**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements. + +**Branch:** `client` +**Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA) + +## Carry-over from Sprint 19 + +None — Sprint 19 complete. #554 (save/load client UI) is finishing in Sprint 19. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #557 | Refactor: game_state.gd derived state in apply_snapshot() | — | +| #558 | Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling | — | +| #559 | Refactor: main.gd god coordinator — extract SnapshotEventRouter | — | +| #560 | Refactor: unify duplicate YAML parsers | — | + +Use `db/connectors/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/architecture.md` — D-020 (Godot is a pure renderer: no game logic in GDScript; GameState reflects server-authoritative data, not derived behavior), D-085 (per-game save directory structure: `user://saves/<timestamp>-<seed>/`, F5=quicksave, F6=quickload, loading screen lists dirs by last-modified), D-088 (3-state pause system: client sends pause requests, server is authoritative) +- `decisions/scope.md` — D-027 (vertical slice success criteria: game session must be resumable for 30-min playthroughs) + +## Notes + +### #557 — Refactor: game_state.gd derived state in apply_snapshot() + +Code review finding: `apply_snapshot()` in `client/scripts/autoloads/game_state.gd` computes two derived values inline: +- `stationary_ticks` (increments when player position hasn't changed) — line 99 / ~line 131-133 +- `current_zone_id` (derived from tile iteration) — line 105 / ~line 286 + +Per D-020, the Godot client is a pure renderer. Behavior-driving computations (stationary tick counting, zone identification) belong in the server, not in `apply_snapshot()`. The server already sends `zone_id` per tile — the client should read it directly rather than re-deriving it. + +What this ticket must deliver: +- Move `stationary_ticks` accumulation out of `apply_snapshot()`. The server sends `stationary_ticks` (or equivalent) in the snapshot — if not yet present, add the field to `ObserverSnapshot` in `client/scripts/protocol/protocol.gd` and mark with a TODO for the server team to populate it. Client reads the server value directly. +- Move `current_zone_id` resolution to a simple property read from the snapshot (`player_tile.zone_id`), removing the tile iteration loop from `apply_snapshot()`. +- After: `apply_snapshot()` contains only direct field assignments from the snapshot dictionary — no conditional logic, no accumulation. +- Add a comment citing D-020 on each removed computation to document the rationale. +- Unit tests: `apply_snapshot()` with a snapshot missing the new fields should degrade gracefully (default values, no crash). + +Integration points: `client/scripts/autoloads/game_state.gd` only. Protocol fields may need a minor extension in `client/scripts/protocol/protocol.gd` — coordinate with server team if new snapshot fields are required. + +Gotcha: `stationary_ticks` drives `ListeningFocus` (D-071) — confirm the server already tracks and sends this value before removing client-side accumulation. If the server does not yet send it, add a feature-flagged fallback that keeps the old behavior with a deprecation comment. + +### #558 — Refactor: dialogue_box.gd direct GameState mutation and AudioManager coupling + +Code review finding: `client/ui/dialogue_box.gd` directly mutates `GameState.dialogue_active` at 3 call sites (lines ~289, ~321, ~334) and calls `AudioManager.apply_dip()` / `AudioManager.clear_dip()` directly. + +Per D-020, UI components should not mutate shared state or call sibling autoloads directly — they should emit signals and let a coordinator (main.gd or a future SnapshotEventRouter) manage cross-component state. + +What this ticket must deliver: +- Replace the 3 `GameState.dialogue_active = true/false` assignments with a signal: `signal dialogue_state_changed(active: bool)`. `main.gd` connects to this signal and updates `GameState.dialogue_active`. +- Replace `AudioManager.apply_dip("dialogue")` and `AudioManager.apply_dip("confrontation")` / `AudioManager.clear_dip()` calls with signals: `signal audio_dip_requested(profile: String)` and `signal audio_dip_cleared()`. `main.gd` connects to these and calls `AudioManager`. +- Result: `dialogue_box.gd` has zero references to `GameState` or `AudioManager`. +- Unit tests: mock signal receivers capture the emitted signals with correct arguments; no direct autoload calls remain. + +Integration points: `client/ui/dialogue_box.gd` (source), `client/scripts/main.gd` (connects to new signals in `_ready()`). No server changes. + +Gotcha: `InputMapper` checks `GameState.dialogue_active` to suppress movement. The signal path adds one frame of latency — verify that the signal fires synchronously within the same frame (use `call_immediate` or connect with `CONNECT_DEFERRED` depending on timing requirements). The `is_dialogue_active()` method on `dialogue_box.gd` (line 337) can remain as a local query without touching `GameState`. + +### #559 — Refactor: main.gd god coordinator — extract SnapshotEventRouter + +Code review finding: `client/scripts/main.gd` is 517 lines and dispatches to 15+ child nodes through a set of `consume_*` methods that all follow the same pattern: read field from snapshot, call method on child node. + +What this ticket must deliver: +- Extract a `SnapshotEventRouter` class (`client/scripts/snapshot_event_router.gd`): takes the snapshot dictionary and routes each field to the correct child node via a registered handler map. +- Registration pattern: `router.register("monologue", monologue_display.consume_monologue)` — callable-based dispatch. Handlers are registered in `main.gd`'s `_ready()`. +- `main.gd` `_process()` calls `router.dispatch(snapshot)` instead of 15+ individual `if snapshot.has("X"): child.consume_X()` blocks. +- `main.gd` retains scene tree ownership (`@onready` node references), camera logic, and input handling — the router only handles snapshot dispatch. +- After: `main.gd` should be under 350 lines. +- Unit tests: construct a `SnapshotEventRouter` with mock handlers, dispatch a snapshot, assert each handler received the correct field value. + +Integration points: `client/scripts/main.gd` (refactor target), new file `client/scripts/snapshot_event_router.gd`. No server changes, no protocol changes. + +Gotcha: Some consume methods in `main.gd` have cross-field dependencies (e.g., camera position depends on both `player_position` and `_camera_anchored` state). Identify these upfront and keep them in `main.gd` directly — only pure per-field dispatch moves to the router. Do not force all logic into the router pattern. + +### #560 — Refactor: unify duplicate YAML parsers + +Code review finding: `client/scripts/checklist/checklist_evaluator.gd` contains its own YAML parser that partially duplicates `client/scripts/autoloads/ui_strings.gd`'s `_parse_yaml()` method. + +What this ticket must deliver: +- Extract a shared `YamlParser` utility class at `client/scripts/util/yaml_parser.gd` (create the `util/` directory). +- `YamlParser` exposes a static method `parse(text: String) -> Dictionary` that handles the common subset of YAML used across both call sites (key: value pairs, nested maps, arrays). +- Replace `checklist_evaluator.gd`'s inline parser with `YamlParser.parse()`. +- Replace `ui_strings.gd`'s `_parse_yaml()` with `YamlParser.parse()` (or delegate to it, keeping the method signature stable). +- Unit tests: parse a sample YAML string with nested keys, arrays, and string values; assert round-trip correctness. + +Integration points: `client/scripts/checklist/checklist_evaluator.gd`, `client/scripts/autoloads/ui_strings.gd`, new `client/scripts/util/yaml_parser.gd`. No server changes. + +Gotcha: The two existing parsers may handle edge cases differently. Write the unit tests first against both parsers to document their current behavior, then unify. Prioritize correctness for existing content files (`client/data/ui-strings.yaml` and any checklist YAML files) — do not break live content. + +## Dependency Chain + +``` +#557 (game_state derived state) ─┐ +#558 (dialogue_box coupling) ├─ all parallel, no inter-dependency +#559 (main.gd SnapshotEventRouter)│ #558 feeds into #559 (signal wiring in main.gd) +#560 (unify YAML parsers) ─┘ +``` + +#558 should complete before #559 so that the new signals from dialogue_box are wired into `main.gd` as part of the router work, not as a separate pass. Otherwise all four tickets run in parallel. + +## PR Workflow + +When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts: + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz \ + --title "feat(client): save/load UI and code quality refactors" \ + --description "body" --base main --head client +``` diff --git a/docs/sprints/sprint-20/joint.md b/docs/sprints/sprint-20/joint.md new file mode 100644 index 000000000..ac18c79ae --- /dev/null +++ b/docs/sprints/sprint-20/joint.md @@ -0,0 +1,74 @@ +# Sprint 20: Shape — Joint Coordination + +**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements. + +## Pre-Sprint Decisions + +No blocking pre-sprint decisions are required. All selected tickets have their upstream decisions confirmed. + +| Decision | Status | Impact | +|----------|--------|--------| +| D-087 (v0.1 triangle configuration) | Confirmed | Server #106/#107/#250 must produce T1-T5 triangle types | +| D-089 (self-contained forks, no cascade) | Confirmed | No cross-triangle state in `TriangleDef` or `TriangleState` | +| D-025 (social site as atomic template unit) | Confirmed | Server #163/#164/#165 schema shapes | +| D-020 (Godot = pure renderer) | Confirmed | Client refactors #557-#560 are motivated by this | + +**One open question to monitor:** +- **Q-028 (line ID collision)** — resolved by D-084 (dual-namespace scheme), but the `RoleCounter` implementation is referenced in D-084 as a requirement for `server/src/content/npc_slug.rs`. Ticket #163 (role definition schema) should create the `server/data/templates/` directory structure; confirm with server team whether the slug counter module belongs in this sprint or the next. + +## Sprint Completion Proof + +The sprint is done when all of the following are observable: + +1. **Template schema compiles and round-trips**: `cargo test -p server -- template` passes. A YAML file at `server/data/templates/sample_role.yaml` deserializes cleanly into a `RoleSchema` struct and re-serializes with identical content. + +2. **Triangle generation produces valid state**: `cargo test -p server -- triangle` passes. A 4-NPC test world with 2 `TriangleDef` entries produces 2 `TriangleState` components with valid role assignments and tension values within the configured range. + +3. **Triangle escalation fires events**: A unit test simulates 60 ticks on a triangle configured to escalate at tick 50, asserts `TriangleCrisisEvent` was emitted at the correct tick. + +4. **Single-ownership model serializes**: A world with 2 templates and a cross-reference survives a save/load round-trip: `TemplateOwnership` components and `TemplateReferenceMap` entries are identical before and after. + +5. **Refactors don't regress tests**: `make ci-client` passes with #557-#560 merged. `game_state.apply_snapshot()` contains no conditional accumulation logic. `dialogue_box.gd` has zero direct references to `GameState` or `AudioManager`. `main.gd` is under 350 lines. + +6. **District layout design decided**: #153 produces a confirmed D-record specifying: complete district topology (how terminal, bar, smuggling corridors, and gate connect), tile dimensions per zone, access topology, and sightline constraints. Unblocks #155 (hand-crafted location authoring) and #188 (triangle instantiation). + +## Test Plan Alignment (D-030) + +Sprint 20 is Phase 3+ territory (D-030 sub-decision #8: Phase 3 = sprint 5+: CauseChain verification + divergent snapshots). The new template/triangle system introduces the first simulation structures that will eventually require CauseChain verification. + +| Ticket | Test scope | Priority | +|--------|------------|----------| +| #163 | Unit: YAML round-trip, constraint validation | High | +| #164 | Unit: spec validation, tile count range | High | +| #165 | Unit: ownership component, reference map | High | +| #106 | Unit: triangle YAML round-trip, conflict validation | High | +| #107 | Unit: constraint satisfaction, minimum 2 triangles | High | +| #250 | Unit: escalation timing, crisis event emission, resolve command | High | +| #557-#560 | Regression: existing test suite must remain green | Medium | +| #153 | Design discussion: district layout confirmed as D-record | High | + +The triangle system's `TriangleCrisisEvent` is the first event candidate for CauseChain integration. Do not wire CauseChain this sprint — but structure the event type so it can carry a `CauseChain` field in a future sprint without breaking callsites. + +## Cross-Team Integration Points + +| Server ticket | Client dependency | Notes | +|---------------|-------------------|-------| +| #165 (`TemplateOwnership` serialization) | None this sprint | Adds fields to `SaveStateV1` — no client protocol change needed until template data is rendered | +| #250 (`TriangleCrisisEvent` in `ObserverSnapshot`) | None this sprint | Event added to snapshot schema as stub — client rendering of triangle state is Sprint 21+ | + +| Planning ticket | Downstream impact | Notes | +|-----------------|-------------------|-------| +| #153 (district layout design) | Unblocks #155, #188 | Layout decisions feed into Sprint 21 hand-crafted location authoring and triangle instantiation | + +No live cross-team protocol dependencies this sprint. Server and client work in parallel. + +## Deferred to Sprint 21 + +The following tickets are natural Sprint 21 candidates once this sprint's foundation lands: + +- **#166** (Template-to-instance mapping) — instantiate templates into the world; requires #163+#164+#165 +- **#161** (Template instantiation engine) — full NPC spawn from template; requires #163+#164+#165+#166 +- **#159** (Tier 2 template definition format) — YAML schema for the full template document; blocked by #163 +- **#108** (Cross-template triangle generation) — requires #106+#107 +- **#109** (Triangle validation) — quality checks on generated triangles; requires #107 +- **#155** (Hand-crafted location authoring) — requires #153 (station district layout design), which is being resolved this sprint on the planning branch diff --git a/docs/sprints/sprint-20/planning.md b/docs/sprints/sprint-20/planning.md new file mode 100644 index 000000000..c86087e37 --- /dev/null +++ b/docs/sprints/sprint-20/planning.md @@ -0,0 +1,82 @@ +# Sprint 20: Shape — Planning Tasks + +**Goal:** Resolve the station district layout design through structured discussion, producing a confirmed D-record that unblocks Sprint 21 location authoring and triangle instantiation. + +**Branch:** `planning` +**Agents:** Gestalt (systems design), Miri (worldbuilding), Araminta (visual/spatial), Tyre (technical feasibility), Paula (narrative), Ozzie (player experience), Qatux (documenter), SI (project manager) + +## Tickets + +| # | Title | Type | Blocks | +|---|-------|------|--------| +| #153 | Station district layout design | design discussion | #155, #188 | + +## Discussion Format + +Ticket #153 is a **design discussion** — workshop-style, run on the planning branch. The output is a confirmed decision record (D-record) in `decisions/content.md` or `decisions/architecture.md`. + +### Context: What Already Exists + +Three spatial layouts have been authored (all by Araminta, Sprint 17): +- **The Terminal** (logistics hub): `docs/design/spatial-layout-terminal-v01.md` — 44×28 tiles, cool grey-navy +- **The Last Shift** (bar): `docs/design/spatial-layout-bar-v01.md` — 28×22 tiles, warm dark amber +- **Smuggling corridors**: `docs/design/spatial-layout-smuggling-corridors-v01.md` — overlay on terminal + bar + maintenance corridors + +Station profile: `docs/design/sova-station-profile.md` — defines 6 districts, only Transit District is playable in v0.1. + +Key decisions already confirmed: +- D-025: Social site / functional cluster as atomic template unit +- D-036: Sova Transit District / Krenn System as v0.1 setting +- D-050: Velen naming and climate + +Open question: Q-036 (district skeleton as generator output) — relevant but not blocking; the v0.1 district is hand-authored. + +### What #153 Must Decide + +The individual locations exist as standalone layouts. What's missing is **how they connect** — the district as a whole: + +1. **District topology**: How do the terminal, bar, gate corridor cluster, and smuggling hideout spaces relate spatially? What corridors connect them? What's the walking distance/time between key locations? + +2. **Gate corridor cluster** (#157): The span gate area — customs, cargo staging, commuter flow. This is the district's entry point and a social chokepoint. Needs spatial spec at the same fidelity as the terminal and bar. + +3. **Access topology**: Public → semi-restricted → restricted zones. How does the access gradient map across the whole district? Where are the boundaries the player must navigate? + +4. **Sightline constraints**: Which locations have line-of-sight to which? This is gameplay-critical — the player's observation opportunities depend on where they can see from where. + +5. **NPC traffic patterns**: How do NPCs flow through the district? Shift changes, commuter routes, social gathering patterns. The spatial layout determines what the player can observe by being in the right place at the right time. + +6. **Total district dimensions**: What's the bounding box? How does tile count affect performance (server spatial queries, client rendering)? + +### Discussion Rounds + +**Round 1 — Inventory and constraints** +Each agent reviews the existing layouts and states what their domain requires from the district layout. Gestalt: gameplay loops that need spatial support. Miri: setting consistency, what the station profile implies. Araminta: visual continuity across zones, tilemap feasibility. Tyre: performance constraints, tilemap size limits. Paula: narrative beats that need specific spatial staging. Ozzie: navigation feel, does the district feel explorable and readable. + +**Round 2 — Topology proposals** +Propose concrete district maps (ASCII or description). How do the existing layouts connect? Where does the gate corridor go? What fills the space between authored locations? + +**Round 3 — Convergence** +Resolve conflicts, pick a topology, specify dimensions. Draft the D-record. + +### Output + +- A confirmed D-record specifying: + - District topology diagram (which locations connect to which, via what corridors) + - Approximate tile dimensions per zone and total district + - Access topology (public/semi-restricted/restricted gradient) + - Key sightline relationships + - Gate corridor cluster spatial spec (or a separate ticket if too large) +- Updated `decisions/` domain file +- Gate corridor layout doc at `docs/design/spatial-layout-gate-v01.md` if produced + +### Reference Files + +Read before starting: +- `docs/design/spatial-layout-terminal-v01.md` +- `docs/design/spatial-layout-bar-v01.md` +- `docs/design/spatial-layout-smuggling-corridors-v01.md` +- `docs/design/sova-station-profile.md` +- `decisions/content.md` — D-025 (social sites), D-036 (Sova setting) +- `decisions/architecture.md` — D-014 (tile-based movement) +- `decisions/perception.md` — D-059 (fog layers, zone temperature) +- `decisions/questions.md` — Q-036 (district skeleton as generator output) diff --git a/docs/sprints/sprint-20/server.md b/docs/sprints/sprint-20/server.md new file mode 100644 index 000000000..19702d172 --- /dev/null +++ b/docs/sprints/sprint-20/server.md @@ -0,0 +1,147 @@ +# Sprint 20: Shape — Server Tasks + +**Goal:** The social site template system gains its foundational schema; triangles become generatable and observable as escalating tensions; the client gains save/load UI and code quality improvements. + +**Branch:** `server` +**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA) + +## Carry-over from Sprint 19 + +None. Sprint 19 treated as complete. + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #163 | Role definition schema | — | +| #164 | Spatial requirement specification | — | +| #165 | Single-ownership model | — | +| #106 | Triangle definition schema | — | +| #107 | Intra-template triangle generation | — | +| #250 | Triangle escalation system | — (#103, #105 done) | + +Use `db/connectors/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/content.md` — D-023 (three-tier content model: Tier 1 drama modules, Tier 2 templates, Tier 3 procedural), D-024 (NPC generation model: 10 axes, triangles as atomic social unit — 2 per template minimum), D-025 (social site / functional cluster as atomic template unit: 4-8 NPCs, 15-40 tiles, single-ownership with reference links), D-029 (population entanglement ratio: 30/50/20 — triangles are the 50% mundane layer) +- `decisions/scope.md` — D-087 (v0.1 triangle configuration: T1 Kael-Smuggler-Ring, T2 Sera-Detective-Commission, T4 Drin-System-Ring as active forks; T3 and T5 as passive tensions), D-089 (self-contained triangle forks for v0.1, no cross-triangle cascade) +- `decisions/architecture.md` — D-010 (deterministic simulation: BTreeMap for all collections, no HashMap), D-026 (simulation tiers: Active-tier NPCs are fully simulated; template instantiation populates Active tier), D-041 (KnowledgeGraph: per-entity component — template instantiation must assign KnowledgeGraph to each spawned NPC) + +## Notes + +### #163 — Role definition schema + +The `RoleDefinition` struct already exists in `server/src/npc/generate.rs` as a procedural generation input — it defines `name`, location pool entries, and per-axis ranges. This ticket extends that to become the canonical Tier 2 role schema. + +What this ticket must deliver: +- A `RoleSchema` type (new, distinct from `RoleDefinition`) in a new `server/src/content/template/` module (or `server/src/content/types.rs` extended). Fields: `role_id: RoleId` (newtype over String), `required_traits: Vec<PersonalityTrait>`, `skill_focus: Vec<Skill>`, `relationship_constraints: Vec<RelationshipConstraint>`, `routine_template: Vec<RoutineEntry>` — these are constraints fed into the NPC generator, not hardcoded values. +- A `RelationshipConstraint` type: `{ with_role: RoleId, kind: RelationshipKind, required_trust: TrustRange }`. Constrains who this role must be in relationship with within the same template. +- YAML deserialization via `serde`. Schema files will live at `server/data/templates/` (create the directory). +- Unit tests: round-trip YAML serialize/deserialize a sample role schema. Validate constraint logic (no self-referential constraints, no duplicate role_id within a template). + +Integration points: `server/src/npc/generate.rs` (`RoleDefinition` → becomes a builder derived from `RoleSchema`), `server/src/content/types.rs` (existing content type infrastructure), `server/src/knowledge/types.rs` (`StableId`, `RelationshipKind`). + +Gotcha: `RoleId` must be stable across save/load — it's a string slug, not a bevy `Entity`. Keep it a newtype over `String` so it serializes cleanly with `StableId`. + +### #164 — Spatial requirement specification + +No existing spatial specification type exists. This is greenfield within the template system. + +What this ticket must deliver: +- A `SpaceSpec` type: `{ tile_count_min: u32, tile_count_max: u32, sightline_zones: Vec<SightlineZone>, privacy_level: PrivacyLevel, traffic_pattern: TrafficPattern }`. +- `SightlineZone`: a named sub-area with a coverage radius in sim tiles (0.5m each, per D-066). Example: `{ name: "bar_counter", radius: 4 }` — 4 sim tiles = 2m clear sightline. +- `PrivacyLevel` enum: `Public`, `SemiPrivate`, `Private`. Governs NPC behavior (NPCs are less likely to disclose secrets in Public spaces). +- `TrafficPattern` enum: `Thoroughfare`, `Destination`, `Restricted`. Governs procedural NPC routine routing through this space. +- YAML deserialization. Schema files co-locate with role schemas at `server/data/templates/`. +- Unit tests: sample spec round-trip, validation that min <= max tile count. + +Integration points: `server/src/content/template/` (new module or extended `server/src/content/types.rs`), future chunk generation (`server/src/simulation/` — spatial specs will inform where templates are placed in the map). No simulation code changes needed this sprint — spec types only. + +Gotcha: Tile counts are in sim tiles (0.5m). A 15-40 visual tile space (per D-025) = 30-80 sim tiles. Document this conversion explicitly in code comments to prevent future confusion. + +### #165 — Single-ownership model + +NPCs are owned by exactly one template, with reference links to others (D-025). No existing ownership component exists. + +What this ticket must deliver: +- A `TemplateOwnership` ECS component: `{ template_id: TemplateId, role_id: RoleId }`. Assigned at template instantiation, never reassigned. +- A `TemplateId` newtype over `u64` — deterministic from world seed + template slug hash. +- A `TemplateReference` struct: `{ from_template: TemplateId, to_template: TemplateId, via_role: RoleId, relationship_metadata: RelationshipKind }`. Stored in a `TemplateReferenceMap` resource (a `BTreeMap<TemplateId, Vec<TemplateReference>>`). +- Logic for lifecycle coordination: when a template is unloaded (NPC tier drops to State-saved or Ungenerated per D-026), `TemplateReference` links are preserved in the serialized state, not destroyed. +- Unit tests: spawn two templates with cross-references, verify `TemplateReferenceMap` entries, verify `TemplateOwnership` components. + +Integration points: `server/src/simulation/tier.rs` (tier transitions must preserve `TemplateOwnership`), `server/src/simulation/save_state.rs` (serialize `TemplateOwnership` and `TemplateReferenceMap` as part of `SaveStateV1` — add fields), `server/src/npc/generate.rs` (generator receives `TemplateId` + `RoleId` at spawn time). + +Gotcha: `TemplateId` from seed + slug hash must be deterministic across save/load — use `StdHasher` is prohibited (non-deterministic), use a seeded hash (e.g., `std::hash::Hasher` from a fixed algorithm) or simply hash the slug string bytes with a fixed polynomial. Log the `TemplateId` computed value in tests for debugging. + +### #106 — Triangle definition schema + +The D-024 spec says triangles are the atomic unit of social intrigue — 2 per template minimum, 1 cross-template. No `TriangleDef` type exists anywhere in the codebase. + +What this ticket must deliver: +- A `TriangleDef` type: `{ triangle_id: TriangleId, roles: [RoleId; 3], conflict_type: ConflictType, interest_axes: [NpcAxis; 3], relationship_constraints: Vec<RelationshipConstraint> }`. Three roles, each with a conflicting axis (Want, Secret, Tolerance, etc.). +- `ConflictType` enum based on D-087 active fork patterns: `ResourceCompetition`, `LoyaltyConflict`, `SecretExposure`, `AuthorityChallenge`. Passive tensions use `LatentTension` variant. +- `TriangleId` newtype over `u64` — deterministic from template seed + role triple. +- Validation: all three roles must be distinct within the template; the conflict type must map to at least one axis divergence (no conflict on identical axis values). +- YAML deserialization. Triangle definitions are authored as part of a template file or as a standalone `triangles.yaml` per template — Dudley to decide the co-location approach. +- Unit tests: sample triangle round-trip, validation for duplicate roles, validation for self-consistent conflict. + +Integration points: `server/src/content/template/` (lives alongside `RoleSchema` and `SpaceSpec`), `server/src/npc/generate.rs` (the generator will consume `TriangleDef` in #107 to assign axis values that produce the desired conflict), `decisions/content.md` D-087 (v0.1 triangles T1-T5 should be expressible in this schema). + +Gotcha: D-089 — self-contained triangles for v0.1, no cross-triangle cascade. Do not add cross-triangle state fields to `TriangleDef`. Cross-template triangles are expressed by a `TriangleDef` that references a `RoleId` from a different `TemplateId` — the cross-template link is in the role, not a special triangle type. + +### #107 — Intra-template triangle generation + +The template system can now describe triangles (#106). This ticket generates them from the description. + +What this ticket must deliver: +- A `generate_intra_template_triangles(world: &mut World, template_id: TemplateId, defs: &[TriangleDef], rng: &mut SimRng) -> Vec<TriangleState>` function. +- `TriangleState` ECS component: `{ triangle_id: TriangleId, role_assignments: BTreeMap<RoleId, StableId>, tension: u8, phase: TrianglePhase }`. `tension` starts at a seeded value within a configured range. `TrianglePhase` enum: `Dormant`, `Simmering`, `Active`, `Resolved`. +- Constraint satisfaction: for each `TriangleDef`, assign generated NPCs (by `StableId`) to the three roles. Validate that the NPC's axis values satisfy the conflict (e.g., for a `LoyaltyConflict`, the NPC filling the `loyalty_torn` role must have a Relationships axis with entries for both of the other two roles). +- Minimum 2 triangles per template — emit an error (not a panic) if the template definition provides fewer than 2 `TriangleDef` entries. +- Unit tests: spawn a 4-NPC template, generate 2 triangles, assert `TriangleState` components exist and role assignments are valid, assert constraint satisfaction. + +Integration points: `server/src/npc/generate.rs` (NPC generation runs first; triangle generation consumes the generated NPCs' axis values), `server/src/content/template/` (#106 types), `server/src/simulation/rng.rs` (`SimRng` for determinism). + +Gotcha: Constraint satisfaction can fail if the NPC pool doesn't provide a suitable candidate for a role. Implement a fallback: if no NPC satisfies the strict constraint, pick the closest match and log a warning. Do not panic — world generation must be robust to imperfect seeds. + +### #250 — Triangle escalation system + +Blockers #103 (relationship dynamics) and #105 (tolerance threshold triggers) are done. `TriangleState` from #107 is available this sprint. + +What this ticket must deliver: +- An ECS system `tick_triangle_escalation` that runs once per game-minute (every 10 ticks per D-031). For each `TriangleState` in `Simmering` or `Active` phase: increment `tension` by a seeded per-triangle rate (drawn from `SimRng` at world-gen time, stored on `TriangleState`). When `tension` exceeds the lowest `ToleranceThreshold` among the triangle's three NPCs, transition `phase` from `Simmering` to `Active`. +- Observable events: when a triangle enters `Active`, emit a `TriangleCrisisEvent` (new event type) containing `triangle_id`, `role_assignments`, and `trigger_npc: StableId`. The monologue system and knowledge system can subscribe to this event — but do not wire those subscribers this sprint. Emit the event; downstream consumption is future work. +- `Resolved` transition: when the player resolves an active fork (mechanism TBD — stub a `ResolveTriangle(TriangleId)` command for now), set `phase = Resolved`. D-089: resolution does not cascade. +- Unit tests: simulate 60 ticks on a triangle with a known tension rate, assert `Active` transition at the expected tick. Test `Resolved` command sets phase correctly. + +Integration points: `server/src/simulation/tier.rs` (`tick_triangle_escalation` only runs on Active-tier NPCs per D-026), `server/src/simulation/time.rs` (game-minute scheduler — 10-tick interval), `server/src/npc/tolerance.rs` (`ToleranceThreshold` component), `server/src/npc/relationships.rs` (`RelationshipGraph` — tension rate influenced by relationship stress), `server/src/bridge/types.rs` (add `TriangleCrisisEvent` to `ObserverSnapshot` for future client rendering). + +Gotcha: Different seeds produce different tolerance thresholds — the same triangle template can escalate in 5 minutes or 30 minutes depending on the seed. This is intentional (D-087). Do not hardcode a tension rate — it must come from `SimRng` at world-gen time and be stored on the component. + +## Dependency Chain + +``` +#163 (Role definition schema) ─┐ +#164 (Spatial requirement spec) ├─ parallel, no inter-dependency +#165 (Single-ownership model) ─┘ + │ + └─ feeds #166 (Template-to-instance mapping, Sprint 21) + +#106 (Triangle definition schema) ──► #107 (Intra-template generation) ──► #250 (Escalation system) + │ + └─ feeds #108 (Cross-template generation, Sprint 21) +``` + +#163/#164/#165 and #106/#107/#250 are two parallel tracks. All six tickets can begin in week 1; #107 and #250 gate on #106 completing first. + +## PR Workflow + +When ready to submit, create a PR with the `tea` CLI. All flags are required to avoid TTY prompts: + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz \ + --title "feat(simulation): social site template schema and triangle system" \ + --description "body" --base main --head server +``` diff --git a/docs/sprints/sprint-21/ci.md b/docs/sprints/sprint-21/ci.md new file mode 100644 index 000000000..7a3590ac6 --- /dev/null +++ b/docs/sprints/sprint-21/ci.md @@ -0,0 +1,49 @@ +# Sprint 21: Instantiate — CI Tasks + +**Goal:** The template system becomes executable — templates spawn NPCs, assign triangles, and place them in world space; cross-template triangles link social sites; the client gains save/load game flow; and the generator pipeline gets its architectural design. + +**Branch:** `ci` +**Agents:** Justine (build/deploy) + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #274 | Move connector scripts from db/connectors/ to tooling/db/ | — | + +Use `db/connectors/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/process.md` — tooling conventions and project structure + +## Notes + +**#274 — Move connector scripts from db/connectors/ to tooling/db/** +- Current location: `db/connectors/` — contains `sqlite_connector.py`, `qdrant_connector.py`, `config.json`, and all wrapper scripts (`sqlite-query`, `sqlite-exec`, `sqlite-init`, `sqlite-seed`, `qdrant-search`, `qdrant-index`, `qdrant-health`, `qdrant-count`, `ticket`, `sprint`, `decision`). +- Target location: `tooling/db/` — consolidates all tooling under `tooling/` per project structure conventions. The `db/` directory retains schema and seed data only. +- Steps: + 1. Create `tooling/db/` directory. + 2. Move all scripts and `config.json`. Preserve executable bits (`chmod +x` on wrapper scripts). + 3. Update `CLAUDE.md` table ("CLI tools" section) to reference new paths. + 4. Update `docs/DEVOPS.md` if it references `db/connectors/` paths. + 5. Update any `Makefile` targets that call `db/connectors/` directly. + 6. Update agent briefings and skill files that reference `db/connectors/` paths — check `.claude/skills/` and `.claude/agents/`. + 7. Leave a `db/connectors/` stub or symlink pointing to `tooling/db/` if any external scripts depend on the old path. Remove after one sprint. +- Do NOT move `db/schema.sql`, `db/seed.sql`, or `settledreach.db` — those stay in `db/`. +- The `settledreach.db` lives in the parent directory (`../settledreach.db` relative to the worktree root) and is not tracked in git — no change needed there. +- Acceptance: `make ci` passes. `db/connectors/ticket list` either works via symlink or has been replaced by `tooling/db/ticket list` everywhere it is referenced. + +## Dependency Chain + +``` +#274 (connector script move) — standalone +``` + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI: + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "chore(ci): move db/connectors/ to tooling/db/" --description "body" --base main --head ci +``` diff --git a/docs/sprints/sprint-21/client.md b/docs/sprints/sprint-21/client.md new file mode 100644 index 000000000..fb5d7f688 --- /dev/null +++ b/docs/sprints/sprint-21/client.md @@ -0,0 +1,56 @@ +# Sprint 21: Instantiate — Client Tasks + +**Goal:** The template system becomes executable — templates spawn NPCs, assign triangles, and place them in world space; cross-template triangles link social sites; the client gains save/load game flow; and the generator pipeline gets its architectural design. + +**Branch:** `client` +**Agents:** Stig (dev), Hoshe (QA) + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #257 | Save/load game flow | — (#256 done) | +| #561 | Housekeeping: move debug_overlay.gd to ui/ directory | — | + +Use `tooling/db/ticket show <id>` for full details on any ticket. + +## Key Decisions + +- `decisions/architecture.md` — D-020 (Godot = pure renderer, no game logic in GDScript), D-085 (per-game save directories under `user://saves/<game-id>/`), D-088 (3-state pause: Normal/Overlay/Paused, server-authoritative) +- `decisions/scope.md` — D-027 (vertical slice: smuggler + detective, two-character proof) + +## Notes + +**#257 — Save/load game flow** +- Server-side serialization (`SaveStateV1`, `SaveLoadCommand`) landed in Sprint 19 (#553, #553). The client `SessionManager` autoload (`client/scripts/autoloads/session_manager.gd`) already creates per-game directories and tracks `current_game_id`. The `input.rs` server-side hook for `SaveLoadCommand::Save` and `SaveLoadCommand::Load` is in place. +- What's missing: the client UI flow — save-to-file and load-from-file screens, and F5/F6 quicksave/quickload keybinds wired to `PlayerInput`. +- `client/ui/main_menu.gd` exists. Add a "Load Game" screen that calls `SessionManager.list_game_dirs()` and lets the player select a save. +- F5 quicksave flow: send `PlayerInput { action: QuickSave }` → server responds with serialised save data → client writes to `user://saves/<game-id>/quicksave.sav`. F6 quickload: reverse. +- Loading screen: a minimal full-screen overlay ("Resuming...") during the round-trip to prevent input during load. No elaborate animation needed for v0.1. +- D-020 constraint: no game logic in client. The client never constructs save data — it only sends the command and receives the file bytes from the server. +- Existing stub in `session_manager.gd` line 71 notes: "The actual F5 save will be wired here once server supports SaveCommand." Server supports it now — wire it. +- Acceptance: (1) F5 in-game triggers quicksave, file appears at correct path. (2) F6 reloads it, player position and NPC state match save. (3) Main menu "Load Game" lists existing saves sorted by date. + +**#561 — Housekeeping: move debug_overlay.gd to ui/ directory** +- `client/scripts/ui/debug_overlay.gd` is the odd one out — all 17 other UI components live in `client/ui/`. This was flagged in a code review. +- Steps: move `client/scripts/ui/debug_overlay.gd` (and its `.uid` file) to `client/ui/debug_overlay.gd`. Update any `preload()` or `load()` references. Update the `.tscn` that instances it if one exists. +- Check `client/scripts/rendering/` and `client/scripts/autoloads/` for any imports of the old path. +- If moving would break more than 3 references and the distinction is intentional (debug overlay is a script, not a scene-based UI), document the distinction in a comment at the top of the file instead, and close the ticket as "documented not moved." +- Acceptance: `make ci-client` passes with the file at its new location, or the distinction is documented in-file. + +## Dependency Chain + +``` +#257 (save/load game flow) — standalone +#561 (debug_overlay housekeeping) — standalone, parallel +``` + +Both tickets are independent and can be developed in parallel. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI: + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): Sprint 21 save/load game flow" --description "body" --base main --head client +``` diff --git a/docs/sprints/sprint-21/joint.md b/docs/sprints/sprint-21/joint.md new file mode 100644 index 000000000..805be7a3f --- /dev/null +++ b/docs/sprints/sprint-21/joint.md @@ -0,0 +1,116 @@ +# Sprint 21: Instantiate — Joint Coordination + +**Goal:** The template system becomes executable — templates spawn NPCs, assign triangles, and place them in world space; cross-template triangles link social sites; the client gains save/load game flow; and the generator pipeline gets its architectural design. + +## Pre-Sprint Decisions + +No blocking pre-sprint decisions are required. All selected implementation tickets have their upstream decisions confirmed. + +| Decision | Status | Impact | +|----------|--------|--------| +| D-025 (social site as atomic template unit) | Confirmed | Server #159/#166/#161 schema and instantiation shapes | +| D-024 (NPC 10-axis model, 2 triangles minimum per template, 1 cross-template) | Confirmed | Server #108/#109 triangle requirements | +| D-087 (v0.1 triangle config: 3 active forks, 2 passive tensions) | Confirmed | #108 must produce triangle types consistent with T1-T5 fork taxonomy | +| D-089 (self-contained forks, no cross-triangle cascade) | Confirmed | #108 cross-template triangle uses reference links, not shared state | +| D-085 (per-game save directories) | Confirmed | Client #257 save/load flow writes to `user://saves/<game-id>/` | +| D-020 (Godot = pure renderer) | Confirmed | Client #257 never constructs save data — sends command, receives file bytes | +| D-059 (fog: five layers, shader-based) | Confirmed | Visual #564 tuning target — alpha values must match D-059 layer specs | +| D-066 (dual-scale grid, 6-8 sim tile fog gradient) | Confirmed | Visual #564 must not alter `CLEAR_THRESHOLD`/`PERIPHERAL_LOW` constants that define gradient width | + +**One open question to monitor:** +- **Q-036 (district skeleton as generator output)** — actively being resolved in #562 this sprint. Resolution will gate #144 (Chunk generation system) and shape the Sprint 22 map pipeline tickets. SI will create follow-up tickets once #562 closes. + +## Planning Ticket + +| # | Title | Team | Agents | +|---|-------|------|--------| +| #562 | Generator architecture workshop — district/block/chunk pipeline | planning | Gestalt, Tyre, Miri, Araminta, Nigel, Qatux, SI | + +**#562 — Generator architecture workshop** + +Workshop brief: `docs/workshops/generator-architecture/workshop-brief.md` + +**Purpose:** Establish the top-down procedural generator pipeline architecture — from geography down to individual chunk fill — that will power the 300-world model. The v0.1 Transit District is hand-authored; this workshop defines what the generator must be able to reproduce and what stub interfaces v0.1 must leave behind. + +**Context documents to read before the discussion:** +- `decisions/content.md` — D-025 (social site template as atomic unit) +- `decisions/scope.md` — D-012 (chunk-based map system), D-036 (Sova as v0.1 setting) +- `decisions/questions.md` — Q-036 (district skeleton as generator output), Q-037 (generator pipeline phases), Q-039 (gate topology generation) +- `docs/design/sova-station-profile.md` — district types and 6-district layout +- `docs/design/spatial-layout-terminal-v01.md`, `docs/design/spatial-layout-bar-v01.md` — hand-authored chunk cluster examples +- `decisions/architecture.md` — D-093/D-094 (Sova spatial hierarchy: chunk/block/district naming and sizes) + +**Three rounds:** +1. **Domain Inventory** — each participant states what their domain requires from the generator (Gestalt: gameplay loop guarantees; Tyre: technical constraints on chunk size and hierarchy depth; Miri: cultural/economic variation inputs for 300 worlds; Araminta: visual coherence constraints on chunk fill and sub-chunk quarter system; Nigel: variation and replayability guarantees). +2. **Pipeline Proposals** — propose pipeline stages, name the spatial hierarchy levels with tile dimensions, describe the district skeleton data structure. +3. **Convergence** — resolve conflicts, lock spatial hierarchy, define district skeleton output format, set v0.1/generator boundary, draft D-record. + +**Required outputs:** +- D-record in `decisions/architecture.md`: pipeline stages, spatial hierarchy, sub-chunk quarter rules, multi-block reservation protocol, district skeleton data structure, v0.1/generator boundary +- Resolution of Q-036 (district skeleton as atomic output — yes/no + formal definition) +- Resolution of Q-037 scope (which phases land in which version window) +- Follow-up implementation tickets: chunk data structure update (#143), district skeleton schema, zoning pass stub, block generation stub + +**SI role in this workshop:** Create follow-up tickets from the D-record outputs and assign to Sprint 22 candidates. Update Q-036 and Q-037 status in `decisions/questions.md`. + +## Sprint Completion Proof + +The sprint is done when all of the following are observable: + +1. **Template instantiation pipeline end-to-end:** Load a Tier 2 YAML from `server/data/templates/`, call the instantiation engine, assert NPCs spawn with correct roles, `TemplateOwnership` set, and 2+ `TriangleState` components generated. `cargo test -p server -- instantiation` passes. + +2. **Cross-template triangle produced:** A test world with two instantiated templates (logistics hub + bar) generates exactly 1 cross-template `TriangleState` with role assignments spanning both templates. `cargo test -p server -- cross_template_triangle` passes. + +3. **Triangle validation catches bad inputs:** Unit tests confirm that a triangle failing conflict viability, relationship coherence, or interest divergence returns a typed `ValidationError`, not a panic. + +4. **Save/load round-trip works end-to-end:** F5 in-game writes a quicksave file to `user://saves/<game-id>/quicksave.sav`. F6 reloads it. Player position and NPC state (including open doors from #246) match the save. `make ci-client` passes. + +5. **Environmental interaction:** A Door entity in a test world toggles walkability on player interaction. An Examinable entity returns examine text. Door state survives a save/load round-trip (open_doors persists in `SaveStateV1`). + +6. **Error handling does not crash:** Sending a malformed IPC message mid-session produces a structured `SimError` response and leaves the server running. Integration test asserts this. + +7. **Fog shader tuned:** Fog Theater gauntlet room — entities in peripheral zone are visibly dimmed but not opaque; deep fog shows a readable zone temperature tint; vision cone edge is soft. `make ci-client` passes. + +8. **Generator architecture decided:** #562 closes with a confirmed D-record, Q-036 marked resolved, Q-037 scope defined. SI creates Sprint 22 candidate tickets for the chunk/district pipeline implementation. + +## Test Plan Alignment (D-030) + +Sprint 21 is Phase 3+ territory. The template instantiation system introduces the first simulation structures that compose content from YAML definitions into live ECS entities. + +| Ticket | Test scope | Priority | +|--------|------------|----------| +| #159 | Unit: YAML round-trip, full Tier 2 document | High | +| #166 | Unit: spawn + TemplateOwnership, TemplateReferenceMap entries | High | +| #161 | Integration: YAML → instantiation engine → ECS entities + triangles | High | +| #108 | Unit: cross-template role assignment, reference link creation | High | +| #109 | Unit: all three validation failure modes + passing case | High | +| #85 | Integration: malformed input → SimError, server survives | High | +| #246 | Unit: Door walkability toggle, examine text return | High | +| #257 | End-to-end: F5 save → F6 load → state match | High | +| #561 | Regression: `make ci-client` passes at new file path | Medium | +| #564 | Visual: Fog Theater gauntlet room manual check | High | +| #274 | Regression: `make ci` passes with scripts at new paths | High | + +## Cross-Team Integration Points + +| Server ticket | Client dependency | Notes | +|---------------|-------------------|-------| +| #246 (`open_doors` in `SaveStateV1`) | #257 (save/load round-trip) | Server must add `open_doors: Vec<StableId>` to save struct before client can verify state restored correctly | +| #85 (`SimError` message type) | #257 (loading screen) | Loading screen should handle `SimError` gracefully — show error state, not spinner forever | + +| Planning ticket | Downstream impact | Notes | +|-----------------|-------------------|-------| +| #562 (generator architecture) | #143, #144 (chunk system) | D-record output gates Sprint 22 chunk pipeline work | + +The #246/#257 dependency is soft — client can stub the door state verification in save/load tests until #246 lands. + +## Deferred to Sprint 22 + +Natural Sprint 22 candidates once this sprint's foundation lands: + +- **#155** (Hand-crafted location authoring) — build the v0.1 Transit District locations in Godot tilemap; requires #153 (done) and the district topology from that D-record +- **#188** (Triangle instantiation in v0.1 content) — wire the 5 v0.1 triangles into the instantiation engine; requires #161 +- **#162** (Storyteller module activation) — draw Tier 1 modules from pool at game start; requires #161 +- **#143** (Chunk data structure) — blocked until #562 workshop defines the spatial hierarchy +- **#176** (NPC pool generation: flat/mundane/entangled ratio) — requires #161 instantiation engine +- Generator pipeline tickets — created by SI from #562 D-record outputs diff --git a/docs/sprints/sprint-21/server.md b/docs/sprints/sprint-21/server.md new file mode 100644 index 000000000..a030f1b07 --- /dev/null +++ b/docs/sprints/sprint-21/server.md @@ -0,0 +1,91 @@ +# Sprint 21: Instantiate — Server Tasks + +**Goal:** The template system becomes executable — templates spawn NPCs, assign triangles, and place them in world space; cross-template triangles link social sites; the client gains save/load game flow; and the generator pipeline gets its architectural design. + +**Branch:** `server` +**Agents:** Dudley (simulation dev), Tyre (arch), Hoshe (QA) + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #159 | Tier 2 template definition format | — (#158 done) | +| #166 | Template-to-instance mapping | — (#163, #164, #165 done) | +| #161 | Template instantiation engine | #166 | +| #108 | Cross-template triangle generation | — (#106, #107 done) | +| #109 | Triangle validation | — (#107 done) | +| #85 | Error handling & recovery | — | +| #246 | Basic environmental interaction | — | + +Use `tooling/db/ticket show <id>` for full details on any ticket. + +## Key Decisions + +- `decisions/content.md` — D-023 (three-tier content model), D-024 (NPC generation model, 10 axes), D-025 (social site as atomic template unit), D-028 (dialogue tagged pools), D-029 (population entanglement ratio) +- `decisions/architecture.md` — D-020 (Godot + Rust IPC, ObserverSnapshot), D-010 (deterministic simulation), D-087 (v0.1 triangle configuration), D-088 (3-state pause, server-authoritative), D-089 (self-contained forks, no cascade) + +## Notes + +**#159 — Tier 2 template definition format** +- `server/src/content/template.rs` already defines `RoleSchema`, `SpaceSpec`, `TriangleDef` (Sprint 20). `#158` (Tier 1 drama module schema) is done — use it as a reference for YAML conventions. +- This ticket extends that to the full Tier 2 document: roles, spaces, triangles, dialogue pool references, NPC routines, and spatial spec in one YAML document. +- `server/data/templates/` directory exists (created by #385). Place the canonical Tier 2 schema definition and at least one authored example template here. +- Acceptance: a complete Tier 2 template YAML round-trips cleanly through `RoleSchema` / `SpaceSpec` deserialization. + +**#166 — Template-to-instance mapping** +- Depends on the schema from #159, but #159 is partially stubbed already — Dudley can start here in parallel if schema is stabilising. +- Core task: given a loaded `TemplateOwnership` + `SpaceSpec`, spawn NPC entities for each role slot, assign relationships, and record the mapping in `TemplateReferenceMap`. +- Existing hook: `server/src/content/spawn.rs`. The `TemplateOwnership` component (Sprint 20) already tracks which template owns an entity. This ticket wires spawning to that system. +- Instance lifecycle: entities spawned from a template must be tagged such that they can be despawned/reset cleanly (gauntlet room reset pattern in `server/src/test_world/` is a reference). +- Acceptance: unit test spawns a 4-NPC template, asserts all role slots filled, `TemplateOwnership` set correctly on each entity, `TemplateReferenceMap` entries present. + +**#161 — Template instantiation engine** +- Blocked by #166. Once mapping works, this ticket wires the full pipeline: load YAML → deserialize → call spawn → generate triangles via `server/src/content/template.rs:assign_triangle_roles()` → register ownership. +- Instance lifecycle management: track active instances, support unloading (for zone transitions and save/load). +- Integration point with #166: the instantiation engine calls the mapping layer, not the raw spawn functions. +- Acceptance: end-to-end test — load `server/data/templates/` logistics_hub YAML, instantiate it, assert NPCs exist with correct roles and 2+ `TriangleState` components generated. + +**#108 — Cross-template triangle generation** +- Sprint 20 landed intra-template triangles (#107). This ticket adds the 1 cross-template triangle required by D-024 ("2 per template minimum, 1 cross-template"). +- The existing `assign_triangle_roles()` in `server/src/content/template.rs` takes `&[TriangleDef]` — extend to accept role slots from two different `TemplateOwnership` sources. +- D-025 ownership model: NPCs are owned by one template but can hold reference roles in another. The cross-template triangle uses `TemplateReferenceMap` reference links (carrying relationship metadata) not direct ownership links. +- Acceptance: test world with two instantiated templates (logistics hub + bar) produces 1 cross-template `TriangleState` with role assignments spanning both templates. + +**#109 — Triangle validation** +- Quality checks on generated triangles. Three checks minimum: (1) conflict viability — the three role slots have at least one opposing Want axis, (2) relationship coherence — at least one Relationships entry links the three roles, (3) interest divergence — no two roles share identical Want+Secret combination. +- Validation runs at instantiation time (not a separate pass). Return `Result<Vec<TriangleState>, ValidationError>` from `assign_triangle_roles()`. +- Hoshe: unit tests for each failure mode — triangle that fails conflict viability, triangle that fails coherence, triangle that fails divergence. +- Acceptance: `cargo test -p server -- triangle_validation` passes, covering all three failure modes plus a valid triangle that passes all checks. + +**#85 — Error handling & recovery** +- Handle three categories: (1) simulation panics / process crashes, (2) protocol deserialization errors, (3) desync detection between client state and server state. +- Server side: `server/src/bridge/local.rs` is the IPC entry point. Add a supervision layer that catches panics from the tick loop and sends a structured `SimError` message to the client before dying, rather than an abrupt disconnect. +- Protocol errors: `server/src/bridge/types.rs` — ensure malformed input returns a typed error response, not a panic. The existing `malformed_input_in_batch_rejects_entire_batch` test (#479, done) is the baseline. +- Desync: add a `state_hash` field to `ObserverSnapshot` (a fast hash of key mutable state — player position, NPC count, tick number). Client logs hash mismatches for debugging. No automatic recovery in v0.1 — detect and report only. +- Acceptance: integration test sends a deliberately malformed message mid-session, asserts the server emits a `SimError` message and continues running (does not exit). + +**#246 — Basic environmental interaction** +- `ObjectType` enum is already in `server/src/bridge/types.rs` (extended by #421, #422). `Interactable` component is in `server/src/simulation/interaction.rs`. +- Currently `ObjectType::Door`, `ObjectType::Terminal`, `ObjectType::Readable`, `ObjectType::Container`, `ObjectType::Furniture` exist with verb sets. +- This ticket: implement the _behaviour_ behind Door (toggle `walkable` on the blocking tile(s), emit a zone-crossable notification), Examinable objects (return examine text from content), and usable Terminals (trigger a `TerminalInteracted` event for future dialogue hook). +- Door state must be tracked in `SaveStateV1` (currently a field gap — add `open_doors: Vec<StableId>` to the save struct in `server/src/simulation/save_state.rs`). +- Acceptance: unit test — player interacts with a Door entity, asserts walkability flips; interacts again, asserts it flips back. Examine on a Readable entity returns non-empty text. + +## Dependency Chain + +``` +#159 (Tier 2 template format) → #166 (template-to-instance mapping) → #161 (instantiation engine) +#107 (done: intra-template triangles) → #108 (cross-template triangles) → #109 (triangle validation) +#85 (error handling) — standalone +#246 (environmental interaction) — standalone +``` + +Parallel tracks: #159→#166→#161 and #108→#109 can run concurrently. #85 and #246 are independent. + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI: + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): Sprint 21 template instantiation" --description "body" --base main --head server +``` diff --git a/docs/sprints/sprint-21/visual.md b/docs/sprints/sprint-21/visual.md new file mode 100644 index 000000000..388d1291a --- /dev/null +++ b/docs/sprints/sprint-21/visual.md @@ -0,0 +1,66 @@ +# Sprint 21: Instantiate — Visual Tasks + +**Goal:** The template system becomes executable — templates spawn NPCs, assign triangles, and place them in world space; cross-template triangles link social sites; the client gains save/load game flow; and the generator pipeline gets its architectural design. + +**Branch:** `visual` +**Agents:** Araminta (art direction) + +## New Tickets + +| # | Title | Blocked by | +|---|-------|------------| +| #564 | Fog shader too opaque — tune alpha for semi-transparent layers per D-059 | — | + +Use `tooling/db/ticket show <id>` for full details. + +## Key Decisions + +- `decisions/perception.md` — D-059 (fog: shader-based, five layers, knowledge-graph-driven), D-011 (fog of perception non-negotiable, same system for NPCs and player) +- `decisions/architecture.md` — D-066 (dual-scale grid: 0.5m sim tiles, 1m visual tiles; fog gradient edge = 6-8 sim tiles = 3-4 visual tiles), D-043 (visual style: "functional warmth", Godot Light2D pipeline, sprites are shape templates the lighting completes) + +## Notes + +**#564 — Fog shader too opaque — tune alpha for semi-transparent layers per D-059** + +The fog shader is implemented and structurally correct. The issue is that the alpha values for Layer 2 (peripheral) and Layer 3 (deep fog) are heavier than D-059 specifies, making the fog feel like a dark wall rather than limited visibility. + +**File to edit:** `client/shaders/fog.gdshader` + +**Current alpha values (reference fog_shader.gd for context):** +- Layer 2 (peripheral, `vis` between `PERIPHERAL_LOW=0.55` and `CLEAR_THRESHOLD=0.85`): alpha `mix(0.55, 0.25, coverage) + noise * 0.1` — peaks at 0.55–0.65 at the peripheral edge +- Layer 3 (deep fog, `explored > 0.3`): alpha `mix(0.78, 0.90, noise_val)` — near-fully opaque + +**D-059 intent:** +- Layer 2 (light fog / peripheral): "desaturated 40–50%, brightness -30%". This implies entities and world geometry are still partially visible — something in the 30–45% alpha range at the peripheral boundary, fading smoothly toward clear. +- Layer 3 (deep fog / previously explored): "near-monochrome with ~10% zone temperature tint". The zone tint (`zone_tint_tex`) must be readable through the fog. The current 0.78–0.90 alpha buries it. Targeting 0.60–0.75 range should let tint breathe without revealing too much detail. +- Layer 1 (clear, vision cone): the soft gradient edge should span 6-8 sim tiles = 3-4 visual tiles (D-066). Verify the `smoothstep(CLEAR_THRESHOLD, 1.0, vis)` range still produces this after alpha changes. `CLEAR_THRESHOLD = 0.85` and `PERIPHERAL_LOW = 0.55` are the knobs — do not change these unless the gradient edge width breaks. + +**What to tune:** +1. Layer 2: reduce the heavy-end alpha from 0.55 → ~0.38, keep the light-end at 0.25. Adjust noise contribution proportionally. Entities behind peripheral fog should be dimmed and desaturated, but recognisable in silhouette. +2. Layer 3: reduce the alpha range from `mix(0.78, 0.90, noise_val)` → `mix(0.62, 0.76, noise_val)`. Zone tint (10% contribution via `mix(vec3(0.04), zone_tint, 0.1)`) should now be faintly visible as a colour cast. The "fog breathes" effect is preserved — keep the noise animation as-is. +3. Verify that Layer 5 (unexplored, no maps, `#12141a`, alpha 1.0) remains fully opaque — information zero, no change. + +**How to test:** +- The Fog Theater gauntlet room (accessible via the Gauntlet hub in the running game) exercises all five fog layers in a single space. +- Observable pass criteria: + 1. An NPC standing in the peripheral zone (Layer 2) is visibly dimmed and slightly desaturated — their D-033 relationship colour is still readable. + 2. A previously-explored room (Layer 3) shows a faint zone temperature tint (bar zone = warm, hub zone = cool, corridor = neutral) rather than uniform near-black. + 3. The vision cone edge is soft — no visible hard line between clear and peripheral. + 4. Unexplored tiles remain fully black. +- Also run `make ci-client` to confirm no shader compilation regressions. + +**Note:** `client/scripts/rendering/fog_shader.gd` (the GDScript controller) does not need changes — it sets uniforms, not alpha values. `client/scripts/autoloads/fog_state.gd` generates the textures fed to the shader — verify the zone tint texture is being populated correctly if Layer 3 tint still doesn't show after alpha reduction. + +## Dependency Chain + +``` +#564 (fog alpha tuning) — standalone +``` + +## PR Workflow + +When ready to submit, create a PR with `tea` CLI: + +```bash +tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "fix(visual): tune fog shader alpha per D-059" --description "body" --base main --head visual +``` diff --git a/docs/sprints/sprint-8/joint.md b/docs/sprints/sprint-8/joint.md index 3d8052b43..b6403c4d2 100644 --- a/docs/sprints/sprint-8/joint.md +++ b/docs/sprints/sprint-8/joint.md @@ -119,7 +119,7 @@ None. All design decisions confirmed. If blockers arise during implementation, e ## Files Written -- /var/home/jeroenschweitzer/Projects/settled-reach/main/docs/sprints/sprint-8/server.md -- /var/home/jeroenschweitzer/Projects/settled-reach/main/docs/sprints/sprint-8/client.md -- /var/home/jeroenschweitzer/Projects/settled-reach/main/docs/sprints/sprint-8/audio.md -- /var/home/jeroenschweitzer/Projects/settled-reach/main/docs/sprints/sprint-8/joint.md +- /var/mnt/data/projects/settled-reach/main/docs/sprints/sprint-8/server.md +- /var/mnt/data/projects/settled-reach/main/docs/sprints/sprint-8/client.md +- /var/mnt/data/projects/settled-reach/main/docs/sprints/sprint-8/audio.md +- /var/mnt/data/projects/settled-reach/main/docs/sprints/sprint-8/joint.md diff --git a/docs/test-plans/sprint-18-client.md b/docs/test-plans/sprint-18-client.md new file mode 100644 index 000000000..c2c25811f --- /dev/null +++ b/docs/test-plans/sprint-18-client.md @@ -0,0 +1,190 @@ +# Test Plan: Sprint 18 — Touch (Client) + +- **Date**: 2026-02-25 +- **Sprint**: 18 (Touch) +- **Spec references**: D-013, D-041, D-042, D-049, D-061, D-062, D-063, D-064, D-078 +- **Tickets**: #151 (minimap rendering), #174 (dialogue UI hardening + examine result), #264 (knowledge/journal display) +- **QA Engineer**: Hoshe + +--- + +## Summary + +Sprint 18 client scope delivers three UI features: +1. **#151** — Minimap overlay rendering POIs from snapshot +2. **#174** — Dialogue UI hardening (D-062/D-063/D-064) + examine result display overlay +3. **#264** — Knowledge/journal panel displaying accumulated KG facts + +Automated tests: `client/tests/test_dialogue_sprint18.gd`, `client/tests/test_journal_sprint18.gd` +Manual tests: this document (section §Manual Test Procedures) + +--- + +## #151: Minimap Rendering + +### Spec reference +D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), D-049 (z-layer 6) + +### Automated (unit-testable) +- `GameState.poi_list` accessible via snapshot or `current_snapshot.poi_list` + → Covered: `test_journal_sprint18.gd::test_gamestate_poi_list_field_exists_or_in_entities` +- `CANVAS_INSERT = 10` sanity check + → Covered: `test_journal_sprint18.gd::test_canvas_insert_constant_is_10` + +### Edge cases +- **Empty POI list**: minimap frame still renders (frame is always present per D-013) +- **POI beyond minimap radius**: renders as directional arrow at border, not dot +- **POI at exactly player position**: dot at center +- **All POI categories**: `NavPoint`, `PersonOfInterest` — distinct colors/shapes + +### Manual test procedure +1. Start game with a clean save (no discovered POIs) +2. **Verify**: minimap insert frame is visible, empty, no dots/arrows +3. Move player near a NavPoint POI; trigger discovery +4. **Verify**: colored dot appears on minimap at correct compass position +5. **Verify**: dot color/shape matches expected category visual (see `data/ui-strings.yaml`) +6. Move player so a POI is beyond minimap radius +7. **Verify**: directional arrow appears at minimap border pointing toward POI +8. **Verify**: player dot remains centered; minimap does not rotate or scroll +9. Open dialogue box; **verify**: minimap remains visible (not hidden by dialogue) + +### Performance check +- 150×150 map, 15 NPCs, 8+ discovered POIs → minimap renders without visible frame drop + +--- + +## #174: Dialogue UI Hardening + Examine Result Display + +### Spec reference +D-061 (box spec), D-062 (invisible locked options), D-063 (confrontation), D-064 (walk-away), D-078 (overheard log) + +### Automated coverage +Test file: `client/tests/test_dialogue_sprint18.gd` + +| Test | D-ref | Status | +|------|-------|--------| +| D-062: rendered options have mouse_filter=STOP | D-062 | Written | +| D-062: no lock icon children on options | D-062 | Written | +| D-062: MAX_OPTIONS = 3 | D-061 | Written | +| D-062: 4 options → only 3 render | D-061/D-062 | Written | +| D-063: CONFRONTATION_BEAT_DURATION in [1.0, 2.0] | D-063 | Written | +| D-063: CONFRONTATION_DIM_ALPHA < 1.0 | D-063 | Written | +| D-063: confrontation_monologue signal fires | D-063 | Written | +| D-063: standard option does NOT fire beat signal | D-063 | Written | +| D-064: _WALK_AWAY_ACTIONS not empty | D-064 | Written | +| D-064: cardinal directions in walk-away list | D-064 | Written | +| D-064: dialogue_dismissed signal exists | D-064 | Written | +| GameState current_dialogue set from snapshot | D-061 | Written | +| GameState current_dialogue null when absent | D-061 | Written | +| GameState current_examine_result field exists | #174 | Written (test-first) | +| GameState current_examine_result set from snapshot | #174 | Written (test-first) | +| GameState current_examine_result null when absent | #174 | Written (test-first) | +| BBCode escape brackets in server text | — | Written | +| _log_dirty flag optimization | — | Written | +| D-061 max height ratio = 0.2 | D-061 | Written | +| D-064 FADE_OUT = 0.3s | D-064 | Written | +| D-078 passive glyph = ┃ | D-078 | Written | + +### Items requiring Stig implementation (test-first stubs will fail until done) +- `GameState.current_examine_result` field + `apply_snapshot()` handler +- Examine result overlay scene (`res://ui/examine_overlay.tscn` or similar) +- Auto-dismiss timer: 4–6 seconds (wire `current_examine_result` to overlay) + +### Manual test procedure — D-062 (invisible locked options) +1. Enter dialogue with an NPC that has some filtered options (server omits locked ones) +2. **Verify**: dialogue box shows only the options the server sent — no grayed-out entries, no lock icons +3. **Verify**: all visible options respond to click/key press +4. **Verify**: pressing 1, 2, 3 selects the corresponding option (key bindings active) +5. **Red flag**: if you see any visual element that appears "disabled" or "locked" — that is a D-062 violation + +### Manual test procedure — D-063 (confrontation beat) +1. Enter dialogue with an NPC that has a confrontation option (italic monologue beat) +2. **Verify**: confrontation option renders identically to standard options (same style — no bold, no icon) +3. Select the confrontation option +4. **Verify**: a first-person internal monologue appears (italic, MonologueDisplay) +5. **Verify**: dialogue box dims for ~1.5 seconds during beat +6. **Verify**: after beat, option is sent and conversation ends normally +7. **Verify**: audio dip applies during confrontation beat + +### Manual test procedure — Examine result display (#174 new feature) +1. Stand adjacent to an NPC; press Examine key (TBD — coordinate with server team) +2. **Verify**: a brief text overlay appears (non-interactive, no response options) +3. **Verify**: overlay is diegetically styled (insert layer, not a dialogue box) +4. **Verify**: overlay auto-dismisses after 4–6 seconds without player input +5. **Verify**: different characters (detective vs smuggler) receive different text for the same NPC +6. **Verify**: overlay does not appear over a dialogue box (mutual exclusion) + +--- + +## #264: Knowledge/Journal Display + +### Spec reference +D-041 (knowledge graph data model), D-042 (UIStrings), D-013 (insert layer) + +### Automated coverage +Test file: `client/tests/test_journal_sprint18.gd` + +| Test | D-ref | Status | +|------|-------|--------| +| GameState.player_knowledge field exists | D-041 | Written (test-first) | +| GameState.player_knowledge set from snapshot | D-041 | Written (test-first) | +| GameState.player_knowledge null when absent | D-041 | Written (test-first) | +| Facts array survives snapshot roundtrip | D-041 | Written (test-first) | +| KnowledgeConfidence levels documented | D-041 | Written | +| Fact state values documented | D-041 | Written | +| Journal scene exists at path | #264 | Written (test-first) | +| UIStrings has journal section | D-042 | Written (test-first) | +| CANVAS_INSERT = 10 | D-013 | Written | +| POI list accessible for minimap | #151 | Written | + +### Items requiring Stig implementation (test-first stubs will fail until done) +- `GameState.player_knowledge` field + `apply_snapshot()` handler +- Journal panel scene (`res://ui/journal_panel.tscn`) +- Journal panel `refresh()` or `_refresh()` method +- UIStrings keys: `journal.title`, `journal.confidence.*`, `journal.no_facts` +- Toggle key (likely `J`) wired to panel visibility +- Mutual exclusion: journal closes when dialogue opens and vice versa + +### Manual test procedure +1. Accumulate KG facts by examining NPCs and participating in dialogue +2. Press the journal toggle key (likely `J`) +3. **Verify**: journal panel opens as an insert-layer overlay (diegetic styling) +4. **Verify**: entities are listed with header ("What I know about Kael Davan") +5. **Verify**: each fact shows: fact text, confidence level, source, game-time timestamp +6. **Verify**: `Direct` confidence facts are most prominent (visually) +7. **Verify**: `Stale` facts appear dimmer than `Active` facts +8. **Verify**: `Contradicted` facts are visually distinct (strikethrough or amber tint) +9. Open dialogue box; **verify**: journal panel closes automatically +10. Close dialogue; re-open journal; **verify**: state preserved +11. Press `J` again; **verify**: journal panel closes + +### Edge cases +- **Empty journal**: no facts accumulated → journal shows "No data" message (UIStrings key) +- **Many facts**: 20+ facts → scroll works, panel stays within insert layer bounds +- **Contradicted + Stale**: a fact can be both — verify combined visual treatment +- **Game time 00:00**: timestamp displays correctly (midnight edge case) + +--- + +## Test Coverage Summary + +| Ticket | Automated tests | Manual procedure documented | Ready to run | +|--------|----------------|----------------------------|--------------| +| #151 minimap | 2 (structural) | Yes | Blocked on Stig (#151 in_progress) | +| #174 dialogue hardening | 25 | Yes | All pass today (code exists) | +| #174 examine result | 5 (test-first) | Yes | Blocked (GameState field missing) | +| #264 journal display | 8 (test-first) | Yes | Blocked (scene + field missing) | + +### Test files to run +```bash +# gdUnit4 headless (see docs/DEVOPS.md for full command) +# test_dialogue_sprint18.gd — expect: 25 pass (dialogue), 5 fail (examine, test-first) +# test_journal_sprint18.gd — expect: 3 pass (constants), 8 fail (test-first) +``` + +### Sprint 18 completion criteria (client) +Per `sprint-18/joint.md`: +- [ ] Minimap renders POIs — discovered POI shows dot; player centered; at least one distant POI shows arrow +- [ ] Journal panel opens — at least one KG fact with confidence, source, game-time visible +- [ ] Examine result displays — overlay fires on Examine, auto-dismisses, differs by character +- [ ] No locked/grayed dialogue options anywhere in the UI (D-062) diff --git a/docs/test-reports/pr-004-protocol-codec-review.md b/docs/test-reports/pr-004-protocol-codec-review.md index 67addbc93..0c21fdcc6 100644 --- a/docs/test-reports/pr-004-protocol-codec-review.md +++ b/docs/test-reports/pr-004-protocol-codec-review.md @@ -17,12 +17,12 @@ The core protocol implementation is sound and test coverage is strong for the ha ## Files Reviewed ### Core Implementation -- `/var/home/jeroenschweitzer/Projects/settled-reach/main/client/scripts/protocol/protocol.gd` (new, 114 lines) -- `/var/home/jeroenschweitzer/Projects/settled-reach/main/client/scripts/autoloads/sim_bridge.gd` (modified, +51 lines) -- `/var/home/jeroenschweitzer/Projects/settled-reach/main/server/tests/gen_fixtures.rs` (new, 63 lines) +- `/var/mnt/data/projects/settled-reach/main/client/scripts/protocol/protocol.gd` (new, 114 lines) +- `/var/mnt/data/projects/settled-reach/main/client/scripts/autoloads/sim_bridge.gd` (modified, +51 lines) +- `/var/mnt/data/projects/settled-reach/main/server/tests/gen_fixtures.rs` (new, 63 lines) ### Tests -- `/var/home/jeroenschweitzer/Projects/settled-reach/main/client/tests/test_protocol.gd` (new, 137 lines, 9 tests) +- `/var/mnt/data/projects/settled-reach/main/client/tests/test_protocol.gd` (new, 137 lines, 9 tests) ### Vendor Library (noted, not reviewed) - `client/addons/messagepack/messagepack.gd` (368 lines, third-party) diff --git a/docs/workshops/character-creation-game-setup/workshop-brief.md b/docs/workshops/character-creation-game-setup/workshop-brief.md new file mode 100644 index 000000000..fc00f8116 --- /dev/null +++ b/docs/workshops/character-creation-game-setup/workshop-brief.md @@ -0,0 +1,375 @@ +# Workshop: Character Creation & Game Setup + +**Date:** 2026-02-25 +**Facilitator:** Team Leader (Jeroen) +**Participants:** Nigel (replayability), Paula (narrative), Gestalt (systems), Miri (worldbuilding), Tyre (architecture), Qatux (documenter) +**Status:** Not started + +--- + +## Purpose + +Define what the character creation / new game screen actually does. This +is the most fundamental unresolved design question in the project: what +does the player choose, what does the seed control, and what does that +combination produce? + +This workshop resolves **Q-011** (character selection and playable +characters) and establishes the boundary between player agency, character +archetype, and world seed. Everything downstream — quest generation, gate +activation, difficulty, replayability — flows from getting this table right. + +## Context + +### What's decided + +- **D-005:** Single character per playthrough. Character choice determines + starting location, starting knowledge, available levers, personal goals. + "Same conspiracy, different character, completely different game." +- **D-027:** v0.1 vertical slice ships with 2 characters: smuggler and + detective. Inverted perspectives on the same world. +- **D-023:** Three-tier content model. Tier 1 (authored drama) drawn from + a pool at game start. Tier 2 (templated). Tier 3 (procedural filler). +- **D-029:** Population entanglement ratio 30/50/20, varies per seed. +- **D-010:** Deterministic simulation. Same seed + same content = identical + world state. +- **D-039:** Wow moment 4 is the Divergence Reveal — same room, different + character, different everything. This is THE replayability payoff. +- **D-041:** Knowledge graph is per-entity. Characters start with different + knowledge. +- **D-032:** Separate monologue pools per character. +- **Seed config schema** (ticket #394): Records seed value, character + selection, pool draws, template assignments, starting knowledge. + +### What's open + +- **Q-011:** Character selection and playable characters — not yet discussed. + Which characters? How different are starting positions? Canon or original? +- **Q-010:** Storyteller AI design — pacing rules, structural vs dramatic + randomness. Adjacent to this workshop but NOT in scope to fully resolve. + +### What's NOT in scope + +- Full character roster beyond v0.1 (two characters first, prove it works) +- Endgame quest design (no endgame exists yet — note toggles, defer design) +- Storyteller pacing algorithm (Q-010 is a separate workshop) +- Character naming edge cases (namespace collisions with NPCs — note, defer) +- Full quest system architecture (scope to: what quest SHAPES are seeded at + creation, not the full quest pipeline) + +### Design guardrail + +Character selection determines your starting **information position** and +**social graph**, not your capabilities. Characters have different verbs +available, not different success probabilities for the same verbs. The +smuggler doesn't get "+5% to cargo inspection" — the smuggler gets "Slip +Manifest" as a verb the detective never sees. This is D-005's intent and +D-028's access tier system in practice. Do not drift toward stat sheets. + +## Key Questions + +### Q1: What does character selection actually select? + +Three possible models — the workshop must pick one (or a hybrid): + +**A. Fixed archetype roster.** Player picks "smuggler" or "detective" from a +list. Each is a fully pre-authored starting position. Knowledge from +playthrough 1 fully transfers — you know exactly where the smuggler starts. + +**B. Archetype + generated instance.** Player picks "smuggler" but YOUR +smuggler is procedurally placed in the social web. Different starting +coworkers, different shift schedule, different corridor assignment. Knowledge +partially transfers — you know smuggler life, but not this smuggler's life. + +**B2. Archetype + curated instance.** Player picks "smuggler," sees 2-3 +procedurally generated social configurations, and picks one. RimWorld's +colonist reroll mechanic — curation from a generated pool rather than full +specification. Middle path between A and B. + +**C. Fully custom.** Player picks background axes (profession, social +tier, faction affinity). No pre-authored archetype. Maximum variation, +maximum authoring cost. + +For each model: what are the replayability implications? What's the +authoring cost? What does this mean for the Divergence Reveal (D-039)? + +Also consider: does the player configure starting knowledge weights, or +is starting knowledge fully determined by archetype? (DF's embark skill +point system lets players shape starting capability within constraints.) + +### Q2: The seed boundary — what varies by what? + +Produce a canonical three-column table: + +| Determined by world seed | Determined by character choice | Player-configured | +|--------------------------|-------------------------------|-------------------| +| ? | ? | ? | + +Examples to place: NPC relationships, which NPCs are compromised, +starting location, starting knowledge graph state, access tiers, +gate activation timing, conspiracy shape, population entanglement +ratio, THE FRIEND identity, starting inventory... + +Include a fourth implicit column: **what information exists in the world +but is inaccessible to this character?** Not locked behind a mechanic — +just absent from their information space entirely. The gap between what +the world contains and what the character can see IS the replayability. +(See: Obra Dinn in Reference Games.) + +This table IS the workshop's primary deliverable. Get it right and every +downstream system knows its inputs. + +### Q3: How does gate activation relate to character choice? + +The contamination/conspiracy discovery trigger — the moment the game shifts +from daily life to investigation. Two sub-questions: + +**A.** Does character choice change WHEN you discover contamination, or only +HOW you experience it? (Detective flags cargo anomalies early via lattice +analysis. Smuggler witnesses something directly. Same world-state, different +discovery paths.) + +Consider Pentiment's model: the murder happens near you, not TO you. You're +pulled in by proximity and relationship, not by being the assigned +investigator. Is gate activation something that happens to the world (and +the character stumbles into it), or something the character triggers through +their specific access? + +**B.** Are discovery paths authored per character (detective always discovers +via X) or emergent (character knowledge graph + storyteller pacing = different +discovery window per playthrough)? What's the minimum authored content needed +per character to make gate activation feel character-specific? + +Explicit RimWorld check: is contamination timing a player-configurable +"storyteller" choice, fixed per character, or emergent from play? Don't +collapse pacing control into archetype selection. + +### Q4: Quest seeding — templates vs randomization + +The user's directive: "the quest system should offer relevant randomized +quests based on creation instead of going fully scripted." Scope this to: + +- What quest SHAPES (not specific quests) are determined at character + creation? (e.g., smuggler gets logistics-flavored side quests, detective + gets investigation-flavored ones) +- How do Tier 2 templates (D-023) interact with character choice? Does the + smuggler's template pool differ from the detective's? +- What is authored (main quest templates, scripted quality) vs generated + (side content, character-relevant variations)? +- How many quest templates are needed per character for v0.1 to feel varied? +- Does the character have visible long-term goals the game tracks? Does the + smuggler TELL you what they want (goals screen) or do wants emerge from + play? (The Sims' wants/aspirations system as reference.) + +### Q5: Game conditions and toggles + +The user wants players to be able to configure their experience. But some +toggles destroy the game's core tension. Define: + +- **What CAN be toggled:** Challenge intensity, optional content modules, + timer pressure, specific life-sim subsystems +- **What CANNOT be toggled:** Core conspiracy simulation, investigator + faction presence, information asymmetry. These exist whether the player + sees them or not. +- **Starting location selection:** Is this a player choice or determined + by archetype? If player choice, what does it mean for authored content? +- **Enable/disable endgame quests:** Note for future design. What's the + minimum we need to decide NOW vs what can wait until endgame exists? + +### Q6: The playthrough 2 test + +Concrete synthesis test. Nigel presents the following scenario to the room: + +> You've played the smuggler on seed X. You now know: Kael is trying to +> exit the ring. Sera Venn is protecting Naia. The detective flagged your +> manifest on Day 2. The contamination hit during the evening shift at +> The Last Shift. You pick the detective on the SAME seed. Minute 1: you +> arrive at the Commission office. Minute 5: your first assignment. +> Minute 10: you walk into The Terminal where you spent 30 hours as the +> smuggler. + +Against the combined design from Rounds 1-4, each participant answers: + +1. What does the detective see in The Terminal that the smuggler never saw? +2. What does the detective's monologue say about Kael — whom the smuggler + considered a friend? +3. Does the contamination trigger differently, or at the same moment via + a different path? +4. Name one thing the player LEARNED in playthrough 1 that changes how + they PLAY playthrough 2 — not metagaming, but genuine new understanding. + +If participants can't answer these concretely, the design has a gap. +Find it and fix it before the workshop closes. + +## Reference Games + +### Dwarf Fortress — the fossil record + +World generation creates centuries of invisible history. The player never +reads a history log — they excavate its consequences. A collapsed +civilization left ruins. A grudge between two species shapes who attacks +your fort. The history is SUBSTRATE, not content. + +The lesson for us: the world seed should produce CONSTRAINTS and RESIDUES +that make the current situation feel inevitable. How long has this smuggling +ring been operating? What's its history of near-discovery? Which institutional +figures already have kompromat on them? The player never sees this directly +but feels its weight on every NPC relationship state they encounter. + +Also relevant: the embark skill point system — players shape starting +capability within constraints rather than receiving a fixed loadout. Consider +for Q1: does the player configure starting knowledge within their archetype? + +### RimWorld — the separation principle + +Storyteller selection (Cassandra/Phoebe/Randy) controls pacing, not content. +Scenario defines starting resources and constraints. Colonist generation is +partially random, partially player-curated (reroll, choose skills). + +**Key lesson 1:** The storyteller and starting conditions are SEPARATE +choices. Our gate activation / contamination pacing (Q3) maps to storyteller +selection. Our character archetype maps to scenario. Don't collapse them. + +**Key lesson 2:** Player CURATION from a procedurally generated set is +different from player SPECIFICATION of a custom set. RimWorld's colonist +reroll is a point-buy system hidden behind a reroll interface. For us: +model B2 — see 2-3 generated social configurations for "your smuggler" and +pick one. This is a viable middle path that deserves to be on the table. + +### The Sims — verbs, not stats + +Traits in The Sims are PERMISSION SYSTEMS for social interactions, not stat +modifiers. The Outgoing Sim doesn't get +20% to social checks — they get +access to DIFFERENT VERBS. They can autonomously initiate conversations the +Introvert Sim cannot. This is exactly our access tier system (D-028 Layer 1). + +**Key lesson 1:** Character creation changes which verbs you have, not how +well you perform shared verbs. The smuggler gets "Slip Manifest." The +detective gets "Pull Records." Neither is better — they're different +information-gathering tools for the same world. + +**Key lesson 2:** Neighborhood placement as replayability driver. The lot +you choose positions you relative to neighbor NPCs, which determines which +relationships bootstrap organically through proximity. Early relationships +form through proximity, not player initiative. The smuggler starts embedded +in The Terminal — relationships with ring members bootstrap before the +player does anything. The detective starts at the Commission — different +organic relationships form. THIS is the structural driver that makes the +same seed play differently. + +### Disco Elysium — observation filters, not capabilities + +D-005 already cites Disco Elysium as a design reference. The Thought +Cabinet is a permission system for new dialogue options and monologue lines. +Building a character in DE doesn't give you stats — it gives you access to +different OBSERVATIONS of the same world. Intellect doesn't make you +smarter — it makes your character say different things to themselves when +they see the same evidence. + +**Key lesson:** Character build determines what your character NOTICES, not +what they can DO. This is D-032 (separate monologue pools) and D-041 +(per-entity knowledge graph) in one reference. If participants are thinking +about Q1 models without DE on the table, they'll drift toward stat-system +thinking. DE keeps them on the observation-filter track. + +### Return of the Obra Dinn — information gap as presence + +Obra Dinn demonstrates that information asymmetry can be the ENTIRE game. +You piece together events from fragments. The information gap feels like +presence, not absence — you FEEL the weight of what you can't see yet. + +**Key lesson for Q2:** The seed boundary table needs to account for what +exists in the world but is invisible to this character. Not locked behind a +mechanic — just absent from their information space. Character A's world +contains things that are simply not in Character B's world. That gap is +the pull that drives playthrough 2. + +### Pentiment — gate activation by proximity + +Pentiment commits to a single-character perspective. The gate activation +question it answers: "what triggers the player from daily life into +investigation?" A murder happens near you, not TO you. You're pulled in +by proximity and relationship, not by institutional assignment. The player +character is NOT the assigned detective — they're a witness with skills. + +**Key lesson for Q3:** Same discovery timing, but character-dependent tools +for responding to it. The contamination doesn't care who you are — it +happens. But your character's position determines whether you see it as +threat, opportunity, or puzzle. + +### The common thread + +In all six games, the setup screen generates ASYMMETRIC STARTING CONDITIONS. +The same world, entered from different positions, produces different +information access, different social proximity, and different verb +availability. The asymmetry IS the replayability. Our character creation +should produce a starting POSITION in an information landscape — not a +stat block, not a story, not a difficulty setting. + +## Participants and Roles + +| Agent | Role | Why they're here | +|-------|------|-----------------| +| Nigel | Replayability lead | Structural randomness, seed design, "what happens on playthrough 10?" | +| Paula | Narrative lead | Starting NPC relationships, character-specific story hooks, social web implications | +| Gestalt | Systems lead | How character choice propagates through mechanics (KG, dialogue tiers, movement, perception) | +| Miri | Worldbuilding | Which archetypes fit the Krenn System, canon constraints, lore accuracy | +| Tyre | Architecture | Implementation cost reality check. "That's 4 new ECS components — is it worth it?" | +| Qatux | Documenter | Track decisions, cross-references, dissent. Maintain running reference list. | + +## Round Structure + +### Round 1 — The Lens Question (Nigel leads) + +Single question: what does character selection actually select? (Q1) + +Nigel opens with a replayability scoring of each model (A/B/B2/C) — 3 +bullet points per model on the replayability axis. This is a BASELINE, not +a verdict. Participants then argue from their domain against that baseline. +This converges faster than open advocacy. + +Target: agree on the model by end of round. + +### Round 2 — The Seed Boundary (Gestalt leads) + +Given the model from Round 1: draw the exact line between world seed, +character choice, and player customization. Produce the three-column +table (Q2). Each participant fills in their domain's rows. + +### Round 3a — Gate Activation (Paula leads) + +How does contamination trigger work per character? (Q3) +This flows directly from Round 1's model decision. Paula leads because +gate activation is fundamentally a narrative question — when does the +story shift? + +### Round 3b — Quest Seeding (Nigel leads, Tyre has implementation floor) + +How do quest templates interact with character choice? (Q4) +Nigel leads because quest variation is the replayability engine. Tyre gets +explicit authority to reject quest template proposals that require new +architecture — every template decision has an implementation cost that +spirals without active checking. + +### Round 4 — Conditions, Toggles & Synthesis (all) + +Game conditions and what's toggleable (Q5). Then run the concrete +playthrough 2 test (Q6). Nigel presents the scenario. Each participant +must answer the four concrete questions. If they can't, the design has +a gap — find it and fix it before closing. + +## Required Reading for Participants + +- `decisions/scope.md` — D-005, D-013, D-027, D-029, D-053 +- `decisions/content.md` — D-023, D-028, D-032, D-034 +- `decisions/architecture.md` — D-041 (knowledge graph) +- `decisions/questions.md` — Q-010, Q-011 + +## Expected Outputs + +- **D-record:** Character creation model (resolves Q-011) +- **D-record:** Seed boundary table (what varies by seed vs character vs player) +- **D-record:** Gate activation trigger design +- **D-record or Q:** Quest seeding model (may produce a Q if full design deferred) +- **D-record:** Game condition toggles (what's configurable, what isn't) +- Ticket updates for Sprint 19+ backlog as needed diff --git a/docs/workshops/content-gap-analysis_v0_1/workshop-outcomes.md b/docs/workshops/content-gap-analysis_v0_1/workshop-outcomes.md new file mode 100644 index 000000000..b4bdedded --- /dev/null +++ b/docs/workshops/content-gap-analysis_v0_1/workshop-outcomes.md @@ -0,0 +1,82 @@ +# Workshop Outcomes: v0.1 Content Gap Analysis + +**Workshop:** v0.1 Content Gap Analysis +**Date:** 2026-02-11 +**Rounds:** 2 (Analysis + Synthesis) +**Participants:** Mellanie, Paula, Araminta, Miri, Gestalt, Ozzie +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** DONE — all decisions actioned, tickets created +**Full notes:** `docs/workshops/content-gap-analysis_v0_1/SUMMARY.md` + +--- + +## What the Workshop Accomplished + +Six agents independently analyzed 9 content layers across the vertical slice (D-027), then synthesized across all outputs. The project lead issued 9 directive decisions between rounds. Remarkable cross-agent convergence: the Dual Lens Guide, monologue as primary carrier, and the tag taxonomy were independently identified by multiple agents without coordination. THE FRIEND concept evolved from Ozzie's emotional instinct to Paula's structural design to Mellanie's authoring plan in a single workshop. + +--- + +## Decisions Produced + +| ID | Decision | Domain | Source | +|----|----------|--------|--------| +| D-032 | Separate monologue pools per character | content.md | Lead directive #1 | +| D-033 | Entity color = relationship to player | perception.md | Araminta R1 + lead directive #2 | +| D-034 | THE FRIEND production-level NPC pattern | content.md | Ozzie concept + lead directive #4 + Paula design | +| D-035 | Converged tag taxonomy for line pools (6+3 tags) | content.md | Gestalt + Mellanie convergence | +| D-036 | Sova Transit District / Krenn System as v0.1 setting | content.md | Miri R1 + lead directive #6 | +| D-037 | Contraband specification | content.md | Miri R1/R2 | +| D-038 | Audio in v0.1 scope via Stable Audio Open (8 files) | scope.md | Lead directives #3 + #9 | +| D-039 | v0.1 wow moment scope — all 6 moments | scope.md | Ozzie R1/R2 + lead directive #8 | +| D-040 | Wiki taxonomy structure | process.md | Miri R2 + lead directive #5 | + +All 9 decisions confirmed by project lead between rounds as non-negotiable directives. + +--- + +## Open Questions Identified + +| ID | Question | Owner | Status | +|----|----------|-------|--------| +| Q-012 | How does the generation expansion pass work? LLM, template-based, or rule-based? | Gestalt, Mellanie | Raised this workshop | +| Q-013 | How does the line previewer handle THE FRIEND's temporal progression? | Gestalt, Dudley | Raised this workshop | +| Q-014 | Audio timing with monologue — when does the chime fire relative to text? | Gestalt, Ozzie | Raised this workshop | +| Q-015 | Does 4x generation expansion apply to THE FRIEND's custom lines? | Mellanie, Gestalt | Raised this workshop | +| Q-016 | Knowledge hierarchy for monologue prerequisites | Gestalt, Paula | Raised this workshop | +| Q-017 | Triangle pressure threshold — what events trigger escalation? | Gestalt, Paula | Raised this workshop | + +--- + +## Tickets Created + +48 total tickets (42 new + 6 updates). See `docs/workshops/content-gap-analysis_v0_1/TICKETS.md` for full list. + +**Critical (9 new):** #297 Kael Davan full profile, #298 Sera Venn full profile, #299 Opening hook (smuggler), #300 Opening hook (detective), #301 Wiki taxonomy, #302 Sova Texture Appendix, #303 v0.1 Visual Grammar, #304 Entity Color System Spec, #305 Dialogue selection pipeline. + +**High (23 new):** Content and narrative (#306, #307, #310, #328), visual and spatial layouts (#311-318), worldbuilding (#319-322), systems and implementation (#308, #309, #323-327). + +**Medium (10 new):** Mirror moments, tutorial content, environmental standards, tell derivation (#329-338). + +**Updated (6):** #261 promoted to critical with expanded scope; #189, #168, #124, #90, #193 updated. + +--- + +## Key Flags + +- Detective's FRIEND confirmed as Sera Venn (Paula, not Mellanie's Lera proposal). Lera Sessik remains the bar owner. +- NPC triangle count: Paula reconciled from 7 to 5 triangles in Round 2. Five-triangle model is canonical. +- Tag taxonomy convergence was independent: Mellanie and Gestalt proposed nearly identical structures without coordination. +- Dual Lens Guide (#261) is the single highest-risk dependency — everything downstream blocks on it. + +--- + +## Critical Path Produced + +Dual Lens Guide (#261) → Voice Kits → THE FRIEND Content Packs → Validation → Content at Scale. + +Three parallel tracks: narrative (Paula), setting (Miri), systems (Gestalt + Araminta) — converging at content pack production. + +--- + +*Compiled by Qatux. Source: `docs/workshops/content-gap-analysis_v0_1/SUMMARY.md`, `TICKETS.md`. Decisions in `decisions/content.md` (D-032 through D-040 excl. D-033 in perception.md, D-038/D-039 in scope.md, D-040 in process.md).* diff --git a/docs/workshops/generator-architecture/araminta-round1.md b/docs/workshops/generator-architecture/araminta-round1.md new file mode 100644 index 000000000..8299e26b8 --- /dev/null +++ b/docs/workshops/generator-architecture/araminta-round1.md @@ -0,0 +1,316 @@ +# Generator Architecture Workshop — Round 1: Araminta +## Visual Coherence Constraints for Chunk Fill + +**Author:** Araminta (Visual Designer) +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (Ticket #562) +**Source documents:** visual-grammar-v01.md, spatial-layout-terminal-v01.md, spatial-layout-bar-v01.md, spatial-layout-gate-v01.md, spatial-layout-smuggling-corridors-v01.md, decisions/content.md (#153 D-record = D-093/D-094), decisions/architecture.md + +--- + +## What the Visual Domain Requires from the Generator + +The visual grammar (visual-grammar-v01.md) establishes a system where the **environment communicates location, not drama** (D-045). This places a strong constraint on the generator: every piece of generated content must resolve to a zone palette, a zone era, and an access tier. Without those three anchors, generated chunks will look random regardless of how technically correct the spatial math is. + +My domain requirement is simple: **the generator must produce chunks that a player can read at a glance**. Read = know where they are, know what tier of access they're in, know where the walls and cover are, and know where people might be watching from. Everything below serves that requirement. + +--- + +## 1. Visual Coherence Constraints for Chunk Fill + +### 1.1 Zone Palette Is the Primary Coherence Anchor + +Each chunk inherits a single zone palette. Zone boundaries are set at block level (64×64 visual tiles), not chunk level. A chunk does not have a mixed palette. If two zones meet at a block boundary, the visual transition happens between chunks, never within a chunk. + +**Locked zone palettes (D-093, visual grammar §1):** + +| Zone | Floor hex | Ambient | Fixture color | Era | +|------|-----------|---------|---------------|-----| +| Gate cluster | `#b8bec4` (surface) | `#0a1222` fog tint | `#f2f4ff` cold LED | Era 3 | +| Terminal | `#1a1e24` | `#0d1520` | `#c8d8f0` cool institutional | Era 1/2 | +| Bar | `#1e1912` | `#200c04` | `#f0b840` amber | Era 3 | +| Maintenance | `#181818` | `#101214` | `#d0d8e0` cold dim | Era 1 | +| Transition corridor | `#181818` (neutral industrial) → `#1e1912` gradient over 40m | varies | sparse cool | Era 1 | + +**Rule:** The generator applies the zone palette to every floor tile and wall face in the chunk. It cannot introduce off-palette materials without an "era modification" flag (see §1.3). An institutional zone chunk with warm amber flooring is a visual error. + +### 1.2 Lighting Fixture Placement Determines Room Scale + +This is the most underappreciated visual constraint: **room width is functionally constrained by fixture radius**. + +From the visual grammar: +- Terminal (institutional): fixture radius 8–10 visual tiles. A room wider than ~20 visual tiles needs a second fixture row. +- Bar (social): fixture radius 5–6 visual tiles. Rooms wider than ~12 visual tiles go dark between pools — which is intentional for private zones, but not for public ones. +- Maintenance: fixture radius 4–5 visual tiles. Gaps between pools are expected and desirable (abandoned, sparse aesthetic). + +**Generator rule:** Chunk fill must place at minimum 1 lighting fixture per zone-appropriate coverage radius². Rooms that exceed 2× coverage radius without a fixture read as broken infrastructure in institutional zones and as suspicious in any zone. The generator's room-dimension choices must respect fixture-coverage budgets for the zone. + +### 1.3 Era Tags Produce Material Variation Without Palette Violation + +Buildings within a block share an era tag. The era tag modifies surface materials while staying within the zone palette range: + +- **Era 1:** Base palette. Older composite panels, original construction. +- **Era 2:** Retrofit elements. Same floor tile base; surface-mounted conduits, junction boxes, modified partitions in `#6d7178` (slightly warmer than Era 1's `#7a7f85`, different production run). Visible as overlay elements on layer 4. +- **Era 3:** Newer construction. Cleaner materials, Commission-grade or commercial finish. Gate cluster is pure Era 3: `#b8bec4` surfaces, uniform LED-white overhead. Bar is Era 3 built by an individual (Lera): same era code, but density of accumulated modifications distinguishes it from institutional Era 3. + +**Generator rule:** Assign era at block generation time, not chunk fill time. Chunk fill inherits era from block. Adjacent blocks can have different eras; the visual transition manifests at block boundary chunks via setbacks, service alleys, or material seams. + +### 1.4 Saturation Hierarchy Must Be Preserved + +D-044 visual hierarchy: entity (40–60% saturation) > objects (15–30%) > structure (5–15%). + +The generator cannot introduce decorative or accent materials that approach entity saturation. Generated floor markings, zone signage, and accent walls must stay in the 5–15% structure tier. If the generator places anything in the 15–30% object tier as environmental decoration (painted walls, colored service doors), it must not exceed D-052 favorite color saturation rules. + +**Hard constraint:** No generated structural or decorative element uses a hex color with saturation above 30%. This is not a style preference — it's a system requirement. If the environment reaches entity saturation levels, the player loses the ability to read NPCs by color (D-033 system breaks). + +### 1.5 Outline Consistency Is Absolute + +`#333340` is the universal outline for all sprites, all zones, all layers. Non-negotiable. Generated content inherits this. The generator does not produce zone-specific outlines or context-specific outlines. Consistency is what makes the visual grammar feel like a coherent world rather than a patchwork of assets. + +### 1.6 LOS Anchor Interval Constraint + +From locked spatial rules (V-05, Workshop #153): structural breaks (walls, pillar clusters) must occur at maximum 16 visual tile intervals in open spaces, with quarter boundaries as the primary anchor points. + +**Why this matters for chunk fill:** Without LOS anchor placement rules, the generator can produce open-floor chunks that are visually incoherent and gameplay-broken simultaneously. A 32×32 visual tile chunk filled with open floor provides no cover, no investigation positions, no spatial drama. The LOS anchor rule is both a visual coherence rule (prevents visual emptiness) and a gameplay rule (prevents cover-free spaces). + +**Generator rule:** Every quarter (16×16 visual tiles) must contain at minimum one structural break that interrupts east-west or north-south LOS across the quarter. This can be a wall partition, a pillar cluster, a furniture arrangement, or a zone transition boundary. The break does not need to be at the exact quarter boundary — it can be within 4 visual tiles of it. + +--- + +## 2. How the Sub-Chunk Quarter System Produces Plausible Streetscapes + +### 2.1 Quarter Dimensions and Their Spatial Meaning + +A chunk is 32×32 visual tiles (32m). Divided into 4 quarters: each quarter is 16×16 visual tiles (16m). + +16m × 16m is a meaningful spatial unit: +- A small shop or office fits in one quarter (with walls and a narrow corridor) +- The Terminal's scanner bay cluster (rows 6–9, ~16×4 visual tiles) is a functional sub-quarter +- The bar's corner booth zone (NW, rows 1–5, ~8×5) occupies roughly one-third of a quarter + +The quarter is the minimum viable building unit. A single-quarter building has room for one social zone, one access entry, and minimal interior subdivision. Two-quarter buildings have room for a public face and a semi-private back. Four-quarter buildings (full chunk) support the complexity of the Terminal or Gate Cluster. + +### 2.2 Street Generation Must Precede Quarter Fill + +Streetscapes are coherent only if streets are determined before buildings. The generator sequence for visual coherence: + +1. **Block level:** Determine street network (which block edges are street-facing, which are back-of-block) +2. **Chunk level:** Determine which chunks within the block contain buildings vs. open space vs. street continuation +3. **Quarter level:** Determine which quarters are filled (building footprint) vs. empty (courtyard, alley, service access) +4. **Fill level:** Place zone-appropriate floor, wall, furniture, and overhead elements within filled quarters + +**Rule:** Building facades must face the nearest street edge. The generator never places a primary building entrance on the back-of-block side. This is what makes a block look like a block rather than a random cluster of structures. + +### 2.3 Facade Rhythm Along a Block Face + +Adjacent filled quarters on a street-facing block edge cannot have identical facade treatments. The visual grammar requires variation without chaos: + +- Vary doorway position (west side / center / east side of facade) between adjacent buildings +- Vary facade depth: some buildings set back 2–3 visual tiles from the block edge, others flush +- Alternate overhead element density (pipes, signage) between adjacent quarters + +**What the hand-authored examples tell us:** +- The Terminal's entry facade (row 01): two doorways spread across 44 visual tiles, with solid wall between them. Rhythm: solid | door | long solid | door | solid. +- The Gate Cluster's concourse south wall (row 33): the primary district-facing facade is essentially unbroken, with the district entry at ground level. Scale communicates institutional weight. +- The Bar's main entrance (row 01): one primary door (west) + one emergency exit (east). Asymmetric, which reads as organic (functional warmth). + +**Generator rule:** Randomly placing doorways produces facades that look like generated content. The generator should select from a small set of facade templates per zone/era/access-tier combination, then apply allowed variation parameters (doorway count, setback, overhead density). Do not treat facade generation as free parameter space. + +### 2.4 Access Tier Gradient Is Spatial, Not Random + +Hand-authored locations consistently follow a gradient: public street face → semi-public entry zone → semi-private interior → private back zones. This is not just a gameplay rule; it's what produces plausible architecture. + +Terminal: Entry lobby (PUBLIC) → Scanner bays (PUBLIC MONITORED) → Main corridor (SEMI-PUBLIC) → Work zones (SEMI-PRIVATE/PRIVATE) +Bar: Entry (PUBLIC) → Main tables (PUBLIC) → Bar counter zone (SEMI-PRIVATE) → Back room (PRIVATE) +Gate cluster: Concourse (PUBLIC) → Customs lanes (SEMI-PRIVATE) → Staging (PRIVATE) → Aperture (RESTRICTED) + +**Generator rule:** The access tier gradient runs north-south or from the street-facing edge inward. The public face is always the face with the most exterior exposure. The private back is always the face furthest from public circulation. Quarters are tagged with access tiers before fill content is selected. Fill content must be appropriate to the tier (no open seating in private zones, no locked doors in public zones without authority-gated reason). + +--- + +## 3. Quarter Merge Rules: What Decides Fill vs. Empty, and Shape + +### 3.1 Three Merge Types and Their Visual Logic + +**2×2 merge (full chunk = 32×32 visual tiles):** +Used for major institutions: primary logistics hubs, government facilities, large commercial buildings. The Terminal is roughly this scale (44×28 — slightly larger than a single chunk, suggesting it spans into a second chunk or uses a non-standard block configuration). The Gate Cluster (40×32) is essentially a full-chunk structure. +Visual requirement: 2×2 merges need a legible building boundary on all four sides. The generator must place a clear facade (wall + doorway treatment) on each exposed edge, not just the street-facing side. + +**1×2 merge (half chunk = 16×32 or 32×16 visual tiles):** +Used for medium-scale spaces: medium offices, workshops, larger commercial venues. This is the most common merge type in a working-class district. +Visual requirement: The long edge of a 1×2 merge is the primary facade. The short edges are either party walls (shared with adjacent building, no exterior treatment) or service edges (minimal, utilitarian). The generator must determine which axis is the long facade axis from the street orientation. + +**L-shape merge (3 quarters):** +This is the "functional warmth" shape — the building that grew organically. The Bar's bathroom corridor extension is a real-world example of L-shape emergence: original rectangular structure + later addition that breaks the rectangle. +Visual requirement: L-shapes need a visual explanation for the notch. Options: +- The notch is a service alley (narrow, maintenance-access, darker floor) +- The notch is a courtyard or outdoor area (open, possibly furniture) +- The notch is a later-era addition seam (visible material change at the join) + +The generator cannot produce L-shapes where the notch is simply empty floor with no visual or functional justification. The notch must be assigned a purpose type. + +### 3.2 Empty Quarter Types + +An empty quarter is not just "no building here." It must be one of: + +| Empty type | Visual treatment | Lighting | Access tier | +|------------|-----------------|----------|-------------| +| Open plaza | Open floor, zone palette, possibly benches | Zone-appropriate fixtures | Public | +| Service alley | Narrow (4–6 visual tiles wide), dark floor, minimal fixtures | Sparse, cold | Semi-private to restricted | +| Courtyard / garden | Open floor, overhead vegetation (layer 4), possibly planters | Natural or warm ambient | Semi-private (enclosed), public (open) | +| Vehicle / cargo staging | Open floor with [CT]-type dock markers, sparse overhead | Industrial, zone palette | Semi-private | +| Structural gap (undeveloped) | Bare floor, no furniture, possibly temporary barriers | No fixtures = very dark | Restricted by default | + +**Generator rule:** Assign empty type at quarter planning time. Use zone and access tier to constrain the available empty types. A gate cluster zone cannot have a courtyard/garden (wrong era, wrong function). A residential zone cannot have cargo staging. + +### 3.3 Fill vs. Empty Ratio by District Density + +The generator needs a "density" parameter per block, derived from zoning and economic tier inputs: + +| Density | Filled quarters per block | Expected visual character | +|---------|--------------------------|--------------------------| +| High (industrial/commercial core) | 12–16 of 16 | Dense block faces, few gaps, tall overhead layers | +| Medium (mixed-use, working class) | 8–12 of 16 | Regular gaps (alleys, small plazas), varied building scales | +| Low (residential fringe, transitional) | 4–8 of 16 | Open courtyards, gardens, undeveloped quarters common | + +**No block should have all 16 quarters filled.** There is always at least one empty quarter per block for service access. This is the generator's hard floor rule: a block with no alleys is architecturally implausible and breaks NPC routing for maintenance-tier characters. + +--- + +## 4. How Multi-Block Structures Read at Their Edges + +Multi-block structures (gate terminals, horizon station access points, government complexes, stadiums, parks) span multiple chunks and must resolve their edges differently from single-chunk buildings. + +### 4.1 The Scale Communication Problem + +A multi-block structure must communicate its scale before the player reaches it. At a glance, it must read as "larger than a single building." The mechanisms: + +1. **Unbroken facade length:** A single-chunk building facade is max 32 visual tiles wide. A multi-block structure has a facade spanning 64, 96, or 128 visual tiles. This length reads as institutional weight. The generator must not break this facade with building-scale interruptions (separate doorways that read as separate buildings). Long facades with wide-spaced feature doorways. + +2. **Elevated overhead layer (layer 4):** Multi-block structures can have overhead elements that span chunk boundaries — roof structures, elevated walkways, ducts — that signal continuity. A single-chunk building has overhead elements bounded by its chunk. A multi-block structure's overhead layer crosses chunk seams. + +3. **Setback buffer zone:** Multi-block structures almost always have a setback from the street — a cleared zone, a plaza, or a service perimeter. This setback is part of the block reservation at the zoning pass. The setback communicates "this building doesn't need to compete for street frontage." + +### 4.2 Edge Chunk Treatment + +The chunks at the edges of a multi-block structure face a specific visual challenge: they're part of a large building but they're adjacent to the district circulation system. + +**Required edge treatments:** + +- **Corner chunks:** Special facade treatment. Not a flat wall turning a 90° corner. Options: angled setback, corner feature (pillar, commission emblem, material accent in zone palette), or service access recessed into the corner. + +- **Entry chunks:** The chunk containing the primary entrance to a multi-block structure. Entry facade must be ≥ 3× the width of the connecting corridor (from V-05). For a district street connecting at 4–6 visual tiles wide, the entry facade must be 12–18+ visual tiles wide with at minimum 2 access doors. The Gate Cluster's concourse south wall (40 visual tiles wide with 2+ entry points) demonstrates this correctly. + +- **Party wall chunks:** Chunks on the boundary between a multi-block structure and adjacent single-chunk buildings. The multi-block structure's wall on this edge needs no decorative treatment — it reads as a party wall, which is architecturally correct and visually clean. + +- **Back-of-building chunks:** Maintenance access for multi-block structures. These chunks face the service spine or maintenance corridors. Visual treatment: utilitarian, dark, Era 1 base materials, minimal lighting. Do not apply the public-facing material treatment to service edges. + +### 4.3 Zone Continuity Across Chunk Boundaries + +A multi-block structure has one zone palette for its entire footprint. The generator must not allow zone-palette variation between the chunks of a single multi-block structure. The Gate Cluster is uniformly `#b8bec4` / cold LED throughout all 40×32 visual tiles. No amber warmth creeps in from the adjacent transit corridor. + +**Hard rule:** Chunk seams within a multi-block structure are invisible at the visual grammar level. Floor tiles, wall materials, and lighting temperature are continuous. The only things that can change at a chunk seam within a building are: functional zones (staging vs. customs vs. concourse) with their associated furniture and overhead elements, not base materials. + +--- + +## 5. Visual Consistency Rules to Prevent Districts from Looking Random + +### 5.1 The Three Consistency Anchors + +A generated district avoids looking random if every chunk can be traced back to three consistent sources: its **zone palette**, its **era**, and its **access tier gradient**. These three together determine: +- What materials appear on the floor and walls +- What modifications and overlays exist +- What kind of furniture and overhead elements are placed +- How light is distributed + +If the generator maintains consistency on these three anchors across all chunks in a district, visual coherence follows automatically. Randomness appears when chunks are filled without reference to these anchors. + +### 5.2 Street Network as Visual Spine + +The district's street network is the visual skeleton. Every building facades toward it; every service access routes away from it. The street network determines orientation for the entire district. + +Visual rules for generated streets: +- Street floor tile uses a dedicated transitional material (not the adjacent building's floor palette — streets are shared infrastructure, not zone-specific) +- Street width by type: maintenance corridor 2vt, internal building 2–4vt, district street 4vt, transition corridor 6vt, gate concourse 8vt (locked in V-05) +- Street lighting: zone-appropriate fixtures placed at regular intervals (every 8–12 visual tiles for primary streets, every 16–20 for secondary) +- Street intersection treatment: clear visual signal that two streets meet (material change at the crossing tile, or a pillar/feature at the corner) + +### 5.3 Landmark Anchors and District Readability + +Hand-authored districts have visual landmarks that orient the player: the terminal's institutional facade, the bar's amber light spill, the gate cluster's administered cold white. Generated districts need the same. + +**Generator rule:** Each district should have minimum 1 landmark structure per block cluster (4 blocks = 1 district quadrant). A landmark structure is a multi-block structure or a building with distinctive visual treatment. The generator reserves landmark slots at the district planning pass, before block generation. Landmark structures get the full multi-block edge treatment (§4). + +Without landmark anchors, generated districts produce visual monotony where every block looks like every other block. The landmark is not about decoration — it's about navigation and spatial orientation. + +### 5.4 Facade Variation Budget + +Adjacent buildings on the same block face must vary in at least 2 of these 5 parameters: +1. Primary entry position (west / center / east of facade) +2. Facade depth (flush / 1–2vt setback / 3–4vt setback) +3. Overhead element density (sparse / moderate / dense) +4. Building height expression (single overhead layer / double overhead layer with structural bridge) +5. Era modification markers (none / minor / heavy) + +If adjacent buildings vary in only 1 or 0 parameters, the block face looks copy-pasted. The generator's facade parameter selection must check the adjacent quarter's choices before committing. + +### 5.5 Lighting Temperature Is Zone-Specific, Not Building-Specific + +The lighting temperature in the visual grammar is set by zone, not by individual buildings. A bar inside an institutional zone gets institutional lighting, not amber. The only exception is player-facing hand-authored social sites where lighting is an authored decision (The Last Shift's amber is Lera's decision, not the zone's character). + +**Generator rule:** Generated buildings inherit their lighting temperature from the zone palette. The generator does not assign lighting temperatures at the building level. Lighting is a zone property. + +This is important because the palette gradient rule (locked in Workshop #153) creates visual territory: +- Gate/official: cool white → cargo/functional: grey-navy → transit neutral: dark → social warm: amber + +A player walking through a generated district should feel the temperature gradient shift as they move from institutional zones into residential or social zones. This gradient is the district's visual fingerprint. + +### 5.6 The "Settled" Principle — Placement Density Communicates Character + +D-051 ("settling is placement"): spaces feel inhabited when they have accumulated objects, not when they have large open areas. Empty space reads as abandoned or transitional. Filled space reads as active. + +For the generator, this translates to a **placed-object density** budget per quarter: + +| Zone type | Objects per quarter (typical) | Notes | +|-----------|-------------------------------|-------| +| Institutional (terminal, gate) | High: 8–14 large objects (terminals, cargo containers, desks) | Functional accumulation | +| Social (bar, market) | Medium-high: 6–10 objects (tables, seating, service equipment) | Personal accumulation | +| Residential | Medium: 4–8 objects (furniture, personal items) | Domestic accumulation | +| Maintenance / service | Low: 1–4 objects (utility equipment, sparse) | Functional minimum | +| Transit / corridor | Very low: 0–2 objects (signage, benches only) | Movement spaces stay clear | + +**The generator must not produce empty rooms.** An empty room is an authored decision (the restricted storage room is sparse by design — contraband doesn't advertise itself). A generated empty room is an unfinished room. If the zone/access tier combination doesn't justify a sparse object count, the generator adds zone-appropriate clutter. + +--- + +## Summary: What the Generator Must Guarantee from a Visual Perspective + +The following visual properties are non-negotiable outputs of any generator pipeline: + +1. **Every chunk belongs to exactly one zone palette.** No mixed-palette chunks. Zone boundaries are block-level decisions. + +2. **Every block has an era tag.** Era modifies surface materials within zone palette bounds. Adjacent blocks may have different eras; the visual transition is handled at block boundary chunks. + +3. **Access tier gradient runs from street face inward.** Public front, private back. This determines facade treatment, interior subdivision, and furniture selection. + +4. **Corridor widths are enforced by type** (V-05): maintenance 2vt, internal building 2–4vt, district street 4vt, transition 6vt, gate concourse 8vt. The generator cannot produce corridors narrower than these minimums. + +5. **LOS anchors exist at max 16vt intervals.** Every quarter contains at minimum one structural break. + +6. **No generated element exceeds structure-tier saturation (15%).** Entity visual hierarchy is inviolable. + +7. **All outlines are `#333340`.** No exceptions. + +8. **Empty quarters have assigned types.** Empty is not null — it is plaza, alley, courtyard, staging, or undeveloped, each with specific visual treatment. + +9. **Multi-block structure facades are unbroken and wide.** Entry facade ≥ 3× connecting corridor width, minimum 2 access doors. + +10. **Lighting temperature is zone-assigned, not building-assigned.** The zone palette determines fixture color temperature. Buildings do not override this. + +11. **Adjacent filled quarters on a street face vary on at minimum 2 facade parameters.** Facade variation is enforced, not random. + +12. **Every district quadrant has at minimum 1 visual landmark.** Landmark slots are reserved at district planning pass before block generation. + +--- + +*These constraints are derived from the hand-authored v0.1 content (Terminal, Bar, Gate Cluster, Smuggling Corridors), the confirmed visual grammar (visual-grammar-v01.md), the locked spatial rules from Workshop #153 (V-05), and the D-records cited throughout. Any generator proposal that violates these constraints will produce visually incoherent output regardless of how correct the spatial math is.* diff --git a/docs/workshops/generator-architecture/araminta-round2.md b/docs/workshops/generator-architecture/araminta-round2.md new file mode 100644 index 000000000..712a9ef01 --- /dev/null +++ b/docs/workshops/generator-architecture/araminta-round2.md @@ -0,0 +1,468 @@ +# Generator Architecture Workshop — Round 2: Araminta +## Visual Coherence for Edge Bleed and Non-Urban Terrain + +**Author:** Araminta (Visual Designer) +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (Ticket #562) +**Responding to:** Round 1 notes (Qatux), Nigel-round1.md (OQ-8 on flavor vocabulary), Ozzie-round1.md (anti-grid, historical palimpsest), Lead Directive (edge bleed, non-urban terrain, multi-playstyle support) + +--- + +## Acknowledging the Lead Directive + +The lead says this is a game about **the inherent asymmetry of human awareness** — not specifically a detective game. I want to say directly: the visual grammar I've built already serves this. Entity colors (D-033) encode the player's *subjective* relationship to every NPC, not the NPC's objective status. Access tier gradients encode social topology, not investigation routes. Lighting temperature communicates "who inhabits this space," not "who the suspect is." + +What I need to do in Round 2 is make explicit that these visual systems serve **all playstyles simultaneously**: +- The investigator reads access tiers as information about where evidence could be hidden +- The career-builder reads access tiers as information about where their workplace authority extends +- The relationship-seeker reads lighting temperature as information about where warmth is +- The explorer reads LOS asymmetry as information about what's worth investigating +- The trader reads object density as information about economic activity + +The grammar doesn't favor the detective. It reads *space as social reality*. That already supports all playstyles. What changes in Round 2 is that the grammar needs to extend to district boundaries and natural terrain — both of which the current spec treats as edge cases. + +--- + +## 1. District Edge Bleed — Gradient Rules and Visual Ambiguity + +### The Core Problem + +A hard district boundary at the 256×256 visual tile line would produce visible seams. A player crossing from an institutional district into a residential one would see the palette snap. That's the grid made visible at district scale — exactly what the lead directive prohibits. + +### The Transitional Block System + +**Rule:** The outermost 1-block ring (64×64 visual tiles wide, the entire perimeter) of every district is a **transitional zone**. It is not fully committed to either the district's zone palette or the adjacent district's zone palette. It blends. + +The blend is not random — it is **directional and material-specific**: + +| Element | Transition rule | +|---------|----------------| +| Floor tiles | Interpolate toward adjacent district over the 64vt block width. At block center (32vt), halfway blend. | +| Wall materials | Do NOT interpolate — walls remain in the building's home district palette. Walls are structural; inconsistent wall materials read as a construction error, not a cultural gradient. | +| Lighting fixture color | Interpolate toward adjacent district temperature over the 64vt block. Fixtures in the edge block use a temperature midway between zone A and zone B. | +| Ambient (CanvasModulate) | Interpolate: the screen-space ambient at the district boundary is the average of both zones' ambient values. | +| Overhead elements (layer 4) | Follow the building's home palette — no interpolation. The overhead layer is attached to structures, not geography. | + +**Example:** Terminal (cool grey `#1a1e24`, fixture `#c8d8f0`) meets Bar zone (warm `#1e1912`, fixture `#f0b840`). The transitional block between them gets floor `#1c1b1b` (average) and fixture temperature `#e0c090` (average — a neutral warm-cool white). The player feels themselves moving between temperature zones. They don't see a line. + +### Shared Infrastructure as Grid Dissolvers + +Infrastructure doesn't respect district lines. The maintenance spine continues across boundaries. Street network continues across boundaries. Power conduits continue. + +**Visual rule:** Any infrastructure element that crosses a district boundary maintains its own visual identity regardless of which zone's palette it passes through. A maintenance corridor that runs through both zones is uniformly `#181818` floor and `#d0d8e0` dim fixtures throughout — the zone doesn't color the infrastructure. + +This produces a specific effect: the player navigating via maintenance corridors and service spines experiences district transitions as gradual warmth or coolness changes in the main spaces they pass through, not as clear demarcation. The maintenance route is the same everywhere; what changes is the ambient leaking through doorways. + +### Palette Gradient Sequence (The Visual Spine of a Planet-Side District) + +From my memory — and now formalized: the palette gradient rule should operate at district scale, not just within a district. + +**The canonical gradient for a settlement:** + +``` +Gate/Official (cool white, high Meridian) + → Cargo/Functional (grey-navy, medium Meridian) + → Transit/Neutral (dark, degraded Meridian) + → Social/Warm (amber, minimal Meridian) + → Residential (deeper warm, very sparse) + → Industrial periphery (cool-neutral, Era 1) + → Agricultural edge / Wilderness (natural ambient) +``` + +A player moving from the gate cluster outward should feel this sequence of temperature changes. Each district boundary is a step along the gradient. No step should be abrupt. The transitional block system produces smooth steps. + +### Visual Ambiguity at the Boundary — The Test + +**Test:** A player who has walked into a space and stopped moving should not be able to say with certainty "I'm in District A" vs. "I'm in District B." They should be able to say "I'm somewhere between institutional and residential." That ambiguity is correct. District boundaries are social constructs; they should feel like zones of contested identity. + +**Mechanism:** Beyond the transitional block palette blend, the boundary zone should contain: +- At least one building whose aesthetic reads ambiguously (a residential building with institutional materials — "this was built during the corporate ownership period") +- At least one infrastructure element that belongs to neither palette (a public bench, a civic planter, a notice board — neutral civic-grey) +- Faction presence that differs from either zone's dominant faction (the boundary is where power is ambiguous) + +--- + +## 2. Non-Urban Terrain Visual Grammar + +### The Problem with the Current Visual Grammar + +My Round 1 grammar assumed fixture-based lighting throughout. Natural terrain has no light fixtures — it has ambient daylight, weather, and time-of-day. The zone palette system needs a natural terrain extension. + +The key difference is not the palette — it's the **lighting model**. Urban zones use `PointLight2D` with defined fixture radii and specific color temperatures. Natural terrain uses **global ambient light** (CanvasModulate adjusted by time-of-day and weather) with local shadow patches (tree canopy, rock shadows, terrain occluders) rather than light pools. + +This is a different visual regime, but it's compatible with the same grammar: it just uses different sources for the same properties (floor color, ambient, lighting temperature, LOS anchors). + +### Natural Zone Palettes + +#### Farmland + +Character: human-worked earth, seasonal rhythm, functional machinery. Warm earth tones crossed with metal and worn wood. + +| Element | Hex | Notes | +|---------|-----|-------| +| Floor — open soil | `#1a1510` | Dark warm brown, turned earth | +| Floor — crop cover (summer) | `#0e1408` | Deep green-dark, living crops overhead | +| Floor — crop cover (dormant) | `#1c1610` | Pale straw-brown, cut stalks | +| Surface structures | `#2a2010` | Dried wood, weathered metal, fence posts | +| Ambient (daylight) | `#0e0c08` | Warm near-black ground shadow | +| Ambient (dusk) | `#120810` | Deep dusty purple-rose | +| Lighting (nocturnal) | `#f0b840` (amber, very sparse) | Oil lamp, generator-powered work light | +| Overhead (canopy) | `#0a1006` at 60% opacity | Crop overhead layer — partial occlusion | + +LOS anchors in farmland: tree lines, fencing, equipment rows, barn structures, irrigation channels. Interval still max 16vt — the open field is broken by these. + +**Object density:** 4–8 per quarter. Crops are floor elements (z=0/1), not objects. Objects are equipment (tractors, plows, silos), structures (barns, sheds, irrigation heads), and markers (fence posts, gate structures). + +#### Wilderness / Forest + +Character: ambient darkness, dense occlusion, no human pattern. This is where both Ozzie's "place I'm not supposed to be" fantasy and the stealth/exploration loop live. + +| Element | Hex | Notes | +|---------|-----|-------| +| Floor — forest floor | `#0e120e` | Dark organic green-brown | +| Floor — undergrowth | `#141a14` | Slightly lighter, mossy texture | +| Floor — clearings | `#181c14` | Open patches, warmer | +| Surface (exposed rock) | `#141618` | Cool grey-blue stone | +| Ambient | `#080a08` | Near-black, very dark | +| Canopy overhead (z=4) | `#0a1008` at 55–75% opacity | Dense forest blocks: variable opacity by canopy thickness | +| Lighting (sparse, nocturnal) | `#c0d8e8` (moonlight) | Cold ambient, from gaps in canopy, not fixture-based | + +LOS in forest: extremely broken. 3–5 visual tile clear vision before a tree line interrupts. The forest is a zone where the player has naturally degraded visibility — not from the fog shader, from dense overhead layer occlusion. + +**Key visual rule for wilderness:** The overhead layer (z=4) does the work that walls do in urban spaces. Dense forest canopy at 70% opacity is the wilderness equivalent of a building interior. The player sees ground-level detail but loses the mid-range LOS that urban spaces provide. + +**Object density:** 0–3 per quarter. Terrain features (fallen logs, rock outcroppings), occasional structures (an abandoned hut, a collapsed wall remnant), natural water features. Never furniture or organized equipment. + +#### Ocean / Coastal Water + +Character: open, reflective, dark, directional light from surface. + +| Element | Hex | Notes | +|---------|-----|-------| +| Deep water floor | `#060c14` | Near-black deep blue | +| Shallow coastal | `#0e1820` | Slightly lighter, sand visible through water | +| Tidal zone | `#181c1a` | Wet rock, dark neutral | +| Ambient | `#060810` | Cold near-black | +| Surface reflection | `#c0c8d8` (specular at 20% opacity, animated) | Starlight/sunlight reflection — visual grammar's water treatment | + +For ocean shores: the "shore" is a transitional band 4–12 visual tiles wide between the natural-water floor palette and the land/beach palette. + +**LOS in water:** Characters on water have dramatically extended LOS (no urban occlusion). Characters observing *from land looking at water* have similar extension — open water is a surveillance dead zone for urban investigations but a clear field for coastal ones. A player on a dock watching boats has long-range LOS that urban environments never permit. + +#### Beach / Coastal + +Character: warm sand tones, open flat, tidal variation. + +| Element | Hex | Notes | +|---------|-----|-------| +| Dry sand | `#2a2218` | Dark warm tan | +| Wet sand | `#201a10` | Darker, where tide has been | +| Dune vegetation | `#121610` | Sparse dark grass | +| Ambient | `#0c0a06` | Warm near-black | +| Lighting | Global ambient, no fixtures in beach zones | Daylight is the light source | + +**Object density:** 0–3 per quarter. Drift material (logs, seaweed, debris), tidal structures (rock pools, seawall sections), human presence markers where applicable (a boat mooring, a net-drying frame). Beach is one of the lowest-density natural zones. + +#### Mountain / High Terrain / Snow + +Character: cold, bright surfaces, compressed atmospheric light, vertically dramatic. + +| Element | Hex | Notes | +|---------|-----|-------| +| Rock face | `#181c22` | Dark blue-grey stone | +| Snow surface | `#c8d8e8` | Pale blue-white — deliberately HIGH brightness contrast | +| Ice | `#aab8c8` | Slightly darker than snow, more specular | +| Ambient | `#10141a` | Cold dark blue-grey | +| Lighting | Global ambient, blue-white shifted (`#d0e4f8` at dawn, `#a8c0e0` at dusk) | Mountain light is directional and cold | + +**Visual grammar challenge:** Snow is bright. It's the only natural terrain type where the floor tile is significantly lighter than the ambient — which inverts the typical relationship (dark floor, lighter fixture pools). This needs special handling: snow tiles have an inherent luminosity value that the ambient doesn't reduce to black. They glow passively. + +**LOS in mountain terrain:** Extremely variable. Cliff faces create absolute LOS walls. Ridgelines create elevation-differential LOS (attacker on ridge sees down; defender below cannot see up). The vertical surprise that Ozzie specifically named as a key spatial experience is native to this terrain type. + +#### Secluded Town / Rural Settlement + +Character: warm residential, low institutional density, personal accumulation (functional warmth at architectural scale). + +This is the planet-side analog of the Bar's aesthetic: human habitation that accumulated rather than was planned. Zone palette: + +| Element | Hex | Notes | +|---------|-----|-------| +| Floor | `#1a1612` | Dark warm brown-grey, worn stone/composite | +| Wall face | `#28201a` | Warm brown — between maintenance and bar zone | +| Ambient | `#0e0c0a` | Warm near-black | +| Lighting | `#f0b840` amber (social, local) + `#d8c890` neutral-warm (commercial, streets) | Mix of fixture temperatures by building type | +| Overhead | Very sparse institutional; high personal accumulated objects | Signs, laundry lines, planters, awnings | + +Secluded towns have higher personal overhead density than any urban zone. Individual character expressed through what's placed on z=4: awnings, potted plants on window ledges, a sign written by hand, a rope stretched between buildings. The institutional overhead layer (pipes, ducts, service equipment) is rare. This is the visual grammar of *individual choices at human scale*. + +### Natural Terrain LOS Anchors + +In natural terrain, the max 16vt LOS anchor interval rule still applies, but anchors are: + +| Anchor type | Visual tile width | Notes | +|-------------|-------------------|-------| +| Single tree | 1–2vt (trunk) | Canopy extends 3–5vt on z=4 | +| Tree cluster | 4–8vt | Full LOS break at floor level | +| Rock formation | 2–4vt | Hard LOS break, permanent | +| Terrain elevation change | 0vt (invisible at floor) | Creates vertical LOS asymmetry | +| Fence/wall | 1vt | Partial cover; human-placed | +| Building | 4–32vt | Full LOS break | +| Water feature (river, stream) | 2–6vt | Not a LOS block but a movement constraint | + +The generator must place natural LOS anchors at the same interval rules as structural elements. A forest clearing that's 30+ visual tiles wide with no trees in it is both visually wrong (natural clearings have fallen logs, undergrowth, isolated trees) and gameplay wrong (no cover in either direction for 30m). + +--- + +## 3. Anti-Grid Techniques + +Ozzie is right: "the grid will show." The block grid is 64×64 visual tiles. Even with quarter variation inside blocks, if block edges always align and streets always run at 90°, the skeleton is perceptible. Here are the visual techniques that break it. + +### Technique 1: Diagonal Connectors + +Streets don't have to run perpendicular. A 45° diagonal connector between two otherwise-gridded streets reads as older than the grid (it predates the block layout, it follows a natural path). + +Visual constraints on diagonals: +- Must be at minimum 4vt wide (same as district street minimum, to handle tile-based movement) +- Floor material is distinct from both connected street systems — diagonal connectors use the transitional corridor palette (`#181818` neutral dark) regardless of what they connect, because they read as "infrastructure that predates the current layout" +- Cannot exceed 45° from grid axis — steeper angles produce tile-movement awkwardness +- LOS along a diagonal is blocked at the two ends by the street network it connects — the diagonal is a slot, not an open approach + +One diagonal connector per 4–6 blocks is sufficient. Too many diagonals produce a different regularity. + +### Technique 2: Irregular Setbacks at Block Faces + +Buildings don't need to be flush with the block edge. Setback variation per building along a block face: + +| Setback | Visual result | What it communicates | +|---------|--------------|----------------------| +| 0vt (flush) | Building wall at block edge | Institutional, dense, planned | +| 1–2vt | Narrow threshold space | Semi-private front threshold, step/stoop | +| 3–4vt | Small garden/forecourt | Residential, some space claimed outside | +| 5–8vt | Significant forecourt | Commercial frontage, civic | +| Recessed door | Door 2–3vt inside flush facade | Industrial — the approach is exposed | + +**Rule:** No two adjacent buildings on the same block face should have identical setbacks. The variation is not random — it's driven by access tier (public buildings set back more) and era (Era 1 buildings are flush, Era 3 buildings have planned setbacks). But within those constraints, the setback varies. + +The aggregated effect of setback variation is a block face that is not a straight wall. The building line is irregular. The player's visual experience of the block's edge is broken into a series of small spatial events rather than a continuous surface. + +### Technique 3: Overhead Extension Past Block Boundaries + +On z=4 (overhead layer), buildings can extend past their ground-floor footprint: +- Awnings and overhangs: 1–3vt extension into the street +- External staircases: 2–4vt extension, adds vertical element +- Second-story or gallery bridges: structural extensions that visually connect two adjacent buildings across the street or alley between them + +Overhead extensions do not block movement (the player moves on z=1 floor). But they change the visual experience of the street: it is partially covered, has variable ceiling height, and — critically — it makes the block edge ambiguous. The building's overhead presence is larger than its footprint. + +**At block seams:** An awning or overhead element that spans a block seam reads as a single building structure regardless of which block generates it. The block seam disappears beneath the visual overhead. + +### Technique 4: Infrastructure Routing at Angle to Grid + +A power conduit, a water pipe, an old rail line that pre-dates the current block layout — if it runs at a slight angle to the street grid, it reads as older than the layout it crosses. + +Visual treatment: +- Infrastructure that runs at angle to grid uses the maintenance corridor palette (`#181818` + `#4e5054` markers) +- It appears on z=4 where it crosses buildings (overhead routing), z=0 where it's underground +- Where it emerges at z=1, it creates small visual events: a junction box, a pressure valve, an access hatch + +The diagonal infrastructure is the generator's primary tool for producing "historical palimpsest" in urban districts. It encodes a simple history: "this predates the current layout." The player doesn't need to be told — they see the conduit cutting diagonally across three blocks and understand that something was here before the current plan. + +### Technique 5: Light Territories vs. Grid Territories + +Light pools from `PointLight2D` are circular. They don't respect block edges. A fixture placed near a block edge casts light into both blocks. The player reads the lit area as a single spatial unit regardless of which block generates it. + +**Exploiting this:** Place fixtures deliberately near block edges, particularly at street intersections, to create light territories that span blocks. The bright zone at an intersection reads as a town square, a gathering point, a visible moment — even if it's just two block corners meeting. + +The grid says: this is the corner of Block 3 and Block 7. The light says: this is *the corner*, a single social fact with meaning. Players navigate by light, not by block IDs. + +### Technique 6: Vegetation Overflow and Organic Intrusion + +In planet-side settings, natural elements can cross block boundaries: +- A tree planted at the edge of a courtyard extends its canopy (z=4) into the adjacent street +- A drainage channel follows gravity rather than block edges — it cuts diagonally through two blocks +- Ivy or climbing vegetation on a building facade extends toward an adjacent building + +These organic elements are generation-time decisions: the generator tags certain building edge quarters as "vegetation boundary permitted" and places the appropriate z=4 elements. The visual result is plant matter that doesn't respect the invisible block lines — exactly what makes planet-side settlements feel like they've been there a while. + +### Technique 7: Street Width Variation + +Streets don't have to be uniform width. The same street can narrow where buildings press in and widen where they set back. This produces the visual impression of a street that *evolved* — some merchants built closer to the edge, others further. + +Implementing within the minimum corridor width rules: +- Minimum 4vt for district streets (V-05) — this is the floor +- Maximum is unconstrained upward — a street can be 8, 12, or 16vt wide at plazas +- The width changes happen at building boundaries (where one building's facade gives way to another's) + +A street that varies from 4vt to 8vt to 6vt along its length reads as human-built. A street that is consistently 4vt everywhere reads as designed. + +--- + +## 4. Unified Empty Quarter Taxonomy + +Qatux correctly flagged that my Round 1 taxonomy (spatial types) and Nigel's taxonomy (content categories) are at different abstraction levels. Here's the unified version. + +### The Two-Layer Model + +Every empty/unclaimed quarter gets: +1. **A spatial type** — the physical geometry and access tier of the space +2. **A content category** — the social/economic activity that inhabits it + +These are assigned independently but have compatibility constraints. + +### Layer 1: Spatial Types (5 canonical) + +| Spatial type | Floor width | Access tier | Ambient character | +|-------------|-------------|-------------|-------------------| +| **Open plaza** | Full quarter (16×16) | Public | Zone palette floor, fixture density normal | +| **Service alley** | 2–6vt wide, full quarter depth | Semi-private → restricted | Dark, sparse fixtures, maintenance palette | +| **Courtyard** | Full quarter interior (enclosed by adjacent buildings) | Semi-private | Reduced ambient, personal-scale objects | +| **Staging ground** | Full quarter, some permanent markers | Semi-private → private | Industrial floor markings, cargo markers | +| **Undeveloped gap** | Any width | Restricted by default | Bare floor, no fixtures, very dark | + +### Layer 2: Content Categories (Nigel's 4 + structural baseline) + +| Content category | Who placed it | Economic signal | Faction signal | +|----------------|---------------|-----------------|----------------| +| **Civic baseline** | No one — it's just maintained infrastructure | Neutral | Neutral (Commission maintains public space) | +| **Informal economy** | Individuals, self-organized | Active trade, self-reliance | Low faction control or contested | +| **Settlement** | Community, long-term residents | Community investment, stability | High community cohesion, low institutional | +| **Economic stress** | Necessity, not choice | Insufficient formal economy | Institutional failure or neglect | +| **Faction presence** | Institutional actor | Faction extending influence | High faction control, normalizing presence | + +### Compatibility Matrix + +| | Open plaza | Service alley | Courtyard | Staging ground | Undeveloped gap | +|---|-----------|--------------|-----------|----------------|-----------------| +| Civic baseline | ✓ primary | ✓ (access infrastructure) | ✓ | ✗ | ✗ | +| Informal economy | ✓ primary | ✓ (side-alley stalls) | ✓ secondary | ✗ | ✓ (squatter use) | +| Settlement | ✓ (public garden) | ✗ | ✓ primary | ✗ | ✓ (shacks) | +| Economic stress | ✓ (abandoned plaza) | ✓ (unauthorized storage) | ✓ | ✓ (mothballed staging) | ✓ primary | +| Faction presence | ✓ primary | ✗ | ✗ | ✓ (checkpoint infrastructure) | ✗ | + +### The Full Unified Taxonomy + +Combining both layers produces the specific fill elements: + +**Open Plaza + Civic baseline:** Benches, news ticker (if near transit), waste receptacles, civic signage. Neutral floor (zone palette). Normal fixture density. Zone: public face of a district. + +**Open Plaza + Informal economy:** Market stalls (temporary awning structures, z=4), vendor cart positions, cluster of seating near central stall. Floor markings from stall activity. Slightly warmer fixture temperature than zone baseline (vendors bring their own lights). Access: nominally public but stall arrangement creates semi-private pockets. + +**Open Plaza + Settlement:** Community garden planters, improvised seating clusters (salvaged furniture, not purchased), a shrine or memorial marker (small z=2 object). Warm, low-tech. The garden has overhead crop layer at z=4. Lighting: minimal, personal-scale. + +**Open Plaza + Economic stress:** Abandoned stalls (awning frames without cloth), cracked floor (visual floor tile variant with cracks — same palette, different texture), no functioning fixtures (dark), possibly a temporary windbreak (partial barrier). Reads as: there used to be activity here. + +**Open Plaza + Faction presence:** Commission kiosk (small structure, institutional-grey palette, z=4 signage in neutral Michroma labels), checkpoint barrier positions (can be raised or lowered), public notice board with official announcements. Cold LED lighting regardless of zone. + +**Service alley + Civic baseline:** Drainage channel, access hatches to z=0 maintenance. Narrow, dark, maintenance palette. Objects: conduit runs, junction boxes. + +**Service alley + Informal economy:** Vendor carts staged here between market hours. Informal repair shop set into an alcove (small bench, tools, spare parts on shelving). Lighting: one additional fixture hung by the vendor, slightly warmer than alley baseline. + +**Service alley + Economic stress:** Unauthorized storage — cargo containers pushed against one wall, none of them labeled correctly. Possibly a temporary shelter structure. Very dark. Access: technically public but social convention says otherwise. + +**Courtyard + Settlement:** Private gardens, seating built into the courtyard walls, personal decorations on the surrounding building faces (z=4 neighbor additions: window boxes, hanging fabric, a clothesline). This is the warmest non-social-site space the generator produces. Enclosed, personal, warm. + +**Courtyard + Informal economy:** Small informal workshop at one end (tools, a bench), goods laid out for inspection or trade. Semi-regular social gathering space — these become recurring NPC locations. + +**Staging ground + Civic baseline:** Vehicle bay positions (dock marker lines on floor), cargo transporter docking points, maintained by the district authority. Clean industrial palette. + +**Staging ground + Economic stress:** Mothballed staging. Old dock points, defunct equipment left in place, no active use. The generators are off — no lighting except ambient bleed from adjacent zones. + +**Staging ground + Faction presence:** Faction-controlled logistics checkpoint. Corporate branded equipment, manifest scanners, branded service vehicles. If Commission-aligned: grey and cold LED. If Syndic-aligned: corporate colors (within saturation rules — muted versions of faction identity). + +**Undeveloped gap + Economic stress:** Primary use. Shack structures (makeshift, Era 0 materials — salvaged, pre-palette), unauthorized occupation. Narrow footprint. Reads as: the gap between buildings that someone decided to live in. + +**Undeveloped gap + Informal economy:** Squatter market — the gap between two buildings has become a covered passage lined with informal trade. Narrow, overhead covered with salvaged material (z=4), dim personal lighting. + +--- + +## 5. Visual Vocabulary for Nigel's Flavor Categories + +Nigel asked specifically for the visual vocabulary that distinguishes his four flavor categories. Here it is — the visual signal the player reads at a glance, before they're close enough to see detail. + +### Category 1: Informal Economy Indicators + +**At-a-glance signal:** Warm irregular lighting against the zone baseline. Awning structures on z=4 that don't align with building facades — they're temporary additions. Goods on the ground (floor-level objects, z=2) in clusters that suggest display, not storage. + +**Visual vocabulary:** +- **Awning material:** Worn fabric or salvaged panel on z=4, at 65% opacity (semi-transparent — you can see through worn fabric). Color: zone-warm variant (amber or dusty orange-brown, staying below 20% saturation). +- **Goods display:** Small objects in regular low-grid patterns (z=2), 0.5–1vt spacing, muted warm-adjacent colors (dried goods, second-hand items — nothing vivid). +- **Vendor lighting:** One additional small PointLight2D per stall, radius 3–4vt, temperature `#f0d090` (warm yellow — warmer than zone fixtures). Creates warm pooling that doesn't match zone's fixture grid. +- **Floor wear:** A visually-distinct floor tile variant in front of each stall — foot-traffic-worn version of the zone palette floor, slightly lighter/warmer. Marks where people stand. +- **Sound indicators (visual grammar §317):** High foot traffic = dense sound ping patterns at market hours. + +**Distinction from faction commercial:** Informal economy has no signage (or handwritten signage in environmental text spec). Faction commercial has printed, standardized signage. + +### Category 2: Settlement Indicators + +**At-a-glance signal:** Organic overhead elements where no overhead elements should be (container gardens on z=4, non-institutional). Seating that's clearly brought from somewhere else (non-matching furniture). The zone palette floor is correct but something on z=4 is soft, plant-based, or personal. + +**Visual vocabulary:** +- **Container gardens:** Rectangular planter objects on z=2 (1×1 or 2×1 visual tiles), with dark soil `#0e1008` top and trailing vegetation on z=4 (`#0a1006` leaf cluster at 60% opacity). Distinctly organic among industrial/institutional surroundings. +- **Improvised seating clusters:** Mismatched furniture objects on z=2. In a zone where all furniture is institutional (same material, same design), non-matching furniture is immediately readable. Warmer material tone than zone standard. +- **Shrine/memorial:** Small z=2 object with accumulated small items around it (the accumulation is visual — a cluster of tiny objects with personal-scale z=4 elements above). No lighting fixture — lit by candle (tiny PointLight2D, radius 1vt, `#ffb040` very warm, dim). +- **Overall ambient:** Warmer than zone baseline in this quarter — the accumulated human presence and personal lighting offsets the institutional zone character. + +**Distinction from informal economy:** Settlement indicators are about residence and community, not trade. No awnings. No goods-on-display floor patterns. The warmth comes from plants and personal objects, not from commercial activity. + +### Category 3: Economic Stress Indicators + +**At-a-glance signal:** Lower lighting than zone baseline (fixtures removed or failed). Structural elements in worse condition (visual tile variants with damage or decay). Objects in wrong positions — cargo pushed against a wall, equipment abandoned mid-task. + +**Visual vocabulary:** +- **Failed/missing fixtures:** Where the zone expects a fixture, there is none (or only a stub — a mounting bracket with no lamp). The area around the gap is darker than zone baseline. This is the clearest single signal of economic stress: lights are out. +- **Damaged floor tile variant:** Same hex values as zone floor, but with crack pattern overlay (z=1 layer, 50% opacity crack texture). The palette is correct; the *condition* is wrong. +- **Abandoned equipment:** Objects in positions that suggest mid-task abandonment — a cargo loader parked at an angle, not docked; a door propped open with a crate; tools left on a workbench with no NPC. Object placement is irregular relative to how the zone normally functions. +- **Temporary shelter construction:** Makeshift wall sections (z=2, non-palette materials — warm salvaged brown or neutral salvaged grey, outside normal zone material set) forming a partial enclosure within the quarter. +- **Sparse foot-traffic floor wear:** Fewer wear marks than zone standard — fewer people use this space than its design intended. + +**Distinction from undeveloped gap:** Economic stress has attempted use. The undeveloped gap has no attempted use — it's raw material. Stress indicators show a space that was used and is now failing. + +### Category 4: Faction Presence Indicators + +**At-a-glance signal:** Standardized signage (printed, institutional) where informal signage would otherwise be. Cold LED lighting regardless of zone temperature. Objects that look like they belong to a larger system — they have the same aesthetic as other faction objects elsewhere. + +**Visual vocabulary:** +- **Commission presence:** Cold institutional grey objects (`#b8bec4` surface material, same as gate cluster). Cold LED fixture on a mounted post (`#f2f4ff` temperature). A notice board with official announcements (environmental text in Michroma, 11px, 70% opacity, properly aligned). The kiosk/checkpoint structure is clearly manufactured, not improvised. +- **Corporate/Syndic presence:** Branded objects — the material is still within zone palette range (corporate entities use zone-appropriate materials but with branded applications). Corporate marking appears as a subtle logo on z=4 or as branded signage. Lighting: slightly cooler and more uniform than zone standard (corporate spaces are *maintained*). +- **Union hall / labor presence:** Notice board with text (layer 4, higher-opacity than Commission notices — these have been posted by people who want them read). Seating arranged for meeting (chairs in a deliberate cluster, not scattered). Informal but organized. +- **Absence of faction presence (readable as absence):** A quarter where there WERE Commission markers and they've been removed — stub mount points visible on z=4, blank walls where signage was. The absence of faction presence is as readable as presence. + +**Distinction from civic baseline:** Civic baseline is neutral, maintained, no faction signage. Faction presence is cold and standardized (Commission) or branded (corporate) or organized-informal (labor). If you see a sign, you're in faction territory. + +--- + +## 6. Responding to OQ-3: Do Quarters Have Social Meaning? + +Ozzie asked whether the choice of quarter fill content has downstream social consequences. The answer from a visual perspective: **yes, and the visual grammar is what makes those consequences legible.** + +When a quarter is assigned "Settlement — Container gardens," the visual output is: +- Container planters in a semi-private courtyard +- Slightly warmer ambient than zone baseline +- Non-institutional overhead elements + +But the *social meaning* that Ozzie wants requires the visual to *communicate* something about who lives here and what kind of space this is. Here's what the visual grammar tells a player who reads it: + +- **Container gardens in an industrial district** → people have been here long enough to invest in food independence. This is a mature community. Slow trust, deep roots. +- **Abandoned equipment (economic stress)** → something changed. People were here, now less so. Ask why. +- **Commission kiosk in a residential zone** → surveillance normalizing in a space it didn't previously reach. Something triggered this expansion. +- **Informal market stalls in a previously-staged zone** → the official function of this space has been displaced by informal economy. Power is contested here. + +These readings are available to any player — not just the investigator. The relationship-seeker reads the container gardens as "warm community, worth investing in." The trader reads the market stalls as "economic activity, possible contacts." The explorer reads the abandoned equipment as "something happened here, worth investigating." + +The visual grammar doesn't point the way. It describes the social reality. The player applies their own lens. + +--- + +## Summary + +**District edge bleed:** Transitional blocks using palette interpolation, shared infrastructure that ignores district lines, light pools that span boundaries. The player feels district changes as gradual temperature shifts, not lines. + +**Non-urban terrain:** Five natural zone palettes (farmland, wilderness, ocean/beach, mountain, secluded town) using global ambient instead of fixture pools. Natural LOS anchors (trees, rocks, terrain features) at the same 16vt max interval rule. Wilderness uses overhead layer density (canopy) to create the occlusion that walls provide in urban spaces. + +**Anti-grid techniques:** Seven techniques. Diagonals as historical infrastructure, irregular setbacks at block faces, overhead extension past block edges, angled infrastructure as palimpsest, light territories that span blocks, vegetation overflow in planet-side settings, street width variation. The visual grammar never aligns perfectly with the block grid — it uses the grid as an invisible scaffold. + +**Unified taxonomy:** Two-layer model — spatial type (my 5 types) × content category (Nigel's 4 + civic baseline) = 25 possible combinations, with a compatibility matrix that constrains illogical assignments. Every quarter is assigned both a type and a category. + +**Flavor visual vocabulary:** Each of Nigel's four categories has a distinct at-a-glance signal: warm irregular lighting (informal economy), organic overhead elements (settlement), failed lighting + damage tiles (stress), cold standardized objects (faction presence). + +**Multi-playstyle acknowledgment:** The visual grammar communicates social reality, not investigation routes. Every player reads the same space through their own lens. The grammar serves them all because it describes who inhabits a space and on whose terms — which is the information that all playstyles need. diff --git a/docs/workshops/generator-architecture/araminta-round3.md b/docs/workshops/generator-architecture/araminta-round3.md new file mode 100644 index 000000000..5b7a507dc --- /dev/null +++ b/docs/workshops/generator-architecture/araminta-round3.md @@ -0,0 +1,490 @@ +# Generator Architecture Workshop — Round 3: Araminta +## Palette Granularity, Organic Streets, Vertical Visuals, Destruction, Horizon, and What's Behind the Wall + +**Author:** Araminta (Visual Designer) +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (Ticket #562) +**Responding to:** Round 2 outputs (all participants), Qatux Round 2 notes, Lead Round 3 directives +**Status:** Round 3 — convergence + +--- + +## Opening: This Round Is About Decisions + +Round 1 was constraints. Round 2 was extension. Round 3 is convergence. I'm going to be more prescriptive here than in previous rounds — these are my recommendations, not open explorations. Where I say "Rule:", that's a proposal for locking. Where I say "open", I'm flagging it for the room. + +One fast note before I start: Ozzie's partial dissent on the grid (OQ-R3-A) is legitimate. My seven anti-grid techniques from Round 2 are visual camouflage, not structural change. I'll address organic/non-rectilinear districts directly in Section 2 and give her an actual answer, not a deflection. + +--- + +## 1. Palette Granularity — The Modifier System + +### The Problem with Six Fixed Palettes + +The lead is right. "Industrial farming ≠ rustic farming" isn't solved by adding more base palettes. If I add a 9th base palette for every agricultural variant, I end up with dozens of fixed entries that still fail to capture the combinatorial variety Miri's cultural ingredients system produces. The correct architecture is a **modifier system** layered on a smaller base palette set. + +The base palettes define floor, ambient, and water (the ground you stand on and the light that falls on it). The modifiers define everything humans have added to that ground. That's the right separation. + +### The Base Terrain Palettes (Revised: 8 Types) + +I'm expanding from 6 to 8 to cover terrain types Round 2 left underspecified: + +| ID | Name | Floor base | Ambient | Water/reflective | +|----|------|------------|---------|-----------------| +| T1 | Temperate farmland | `#1a1510` dark warm brown | `#0e0c08` warm near-black | n/a | +| T2 | Industrial/greenhouse | `#141618` grey-green dark | `#0c0e0c` flat cool | n/a | +| T3 | Forest/wilderness | `#0e120e` organic dark | `#080a08` near-black | n/a | +| T4 | Grassland/plains | `#141810` muted green-grey | `#0c0e08` cool-warm | n/a | +| T5 | Coastal water | `#060c14` deep near-black blue | `#060810` cold near-black | `#c0c8d8` specular animated | +| T6 | Beach/coastal margin | `#2a2218` warm dark tan | `#0c0a06` warm near-black | n/a | +| T7 | Mountain/high terrain | `#181c22` dark blue-grey stone / `#c8d8e8` snow | `#10141a` cold dark | n/a | +| T8 | Desert/arid | `#221c12` dusty warm dark | `#10100c` warm-neutral | n/a | + +T1 and T2 are the two farmland base types — they differ at the floor and ambient level. Industrial farms (T2) have a distinctly greyer ambient because they operate under artificial light even outdoors. Rustic farms (T1) have warm organic ground. + +This is the only place where I'm adding two entries for the same terrain category — because the lead specifically called out this distinction, and it genuinely affects the ambient regime (natural light vs. artificial) not just the structures on top. + +### The Three Modifier Axes + +Every non-urban terrain district gets three modifier assignments, drawn from the society profile: + +**Modifier A: Structure Material Character** (from heritage root) + +| Heritage root | Structure material | Surface treatment | Overhead character | +|---------------|--------------------|-------------------|-------------------| +| Iron | Corrugated metal, welded joints | Cold precise | Functional metal (ducts, conduits exposed) | +| Stone | Carved stone, thick masonry | Cool solid | Low overhead, heavy permanent structures | +| Frost | Sparse insulated panel, minimal | Cold minimal | Very sparse; exposure-resistant | +| Vine | Timber, organic fibers, woven | Warm organic | Dense personal overhead (drying racks, planters) | +| Tide | Weathered timber, rope, marine metal | Salt-worn neutral | Maritime: nets, moorings, tackle | +| Dust | Rammed earth, fired clay | Warm-brown dry | Low; heat-efficient, minimal projection | +| Salt | Preserved wood, sealed containers | Neutral functional | Practical personal (salt storage, preservation apparatus) | +| Arc | Mixed-material, jury-rigged | Variable | Dense improvised overhead (whatever was available) | +| Jade | Refined composite, polished | Cool refined | Deliberate aesthetic overhead (trellises, decorative structures) | +| Spice | Vivid dyed textile over standard base | Warm + accent | Dense fabric overhead, colorful within saturation rules | + +**Rule:** Heritage root is the primary modifier for structure material. In a two-root heritage blend, the dominant root (highest weight) determines material; the secondary root adds accent elements in the overhead layer. + +**Modifier B: Economic Tier** (from economic function + economic pressure combination) + +| Tier | Condition | Object density | Lighting presence | +|------|-----------|----------------|-------------------| +| Prosperous | Maintained, new materials | High (full budget) | Full fixtures, maintained | +| Standard | Functional, showing age | Moderate | Fixtures present, some failed | +| Subsistence | Worn, improvised repairs | Low | Minimal; improvised warm sources | +| Failing | Degraded, abandoned sections | Very low | Failed fixtures; dark | + +**Rule:** Economic tier modifies *condition* and *density*, not palette. A Stone-heritage prosperous farm and an Iron-heritage prosperous farm have different materials but similar density and maintenance. Economic tier crosses material character cleanly. + +**Modifier C: Era** (from block era tag) + +| Era | Material generation | Structural scale | Infrastructure visible? | +|-----|---------------------|------------------|------------------------| +| Era 1 | Hand-built; organic/natural construction | Human-scale, small | None visible | +| Era 2 | Standardized; mixed natural and fabricated | Intermediate | Some surface-mounted | +| Era 3 | Modern/industrial; fabricated, uniform | Large-scale possible | Integrated, less visible | + +Era affects the GENERATION of structures within the chosen material character. An Iron-heritage farm that was built in Era 3 has precisely welded industrial metal structures. An Iron-heritage farm built in Era 1 has hammered metal over stone foundations. Same heritage root, different era expression. + +### Faction Overlay (Optional, Additive) + +Applied on top of any base palette + modifier combination: + +| Faction | Overlay effect | +|---------|---------------| +| Commission | Cold LED work lights replace warm sources; institutional grey secondary structures (checkpoint posts, monitoring equipment); Michroma signage | +| Syndic-corporate | Branded secondary structures; slightly colder, more uniform lighting than zone standard; corporate marking on z=4 | +| Independent (labor) | Personal accumulated objects; union notice boards; warm improvised fixtures | +| None | No overlay; pure heritage + economic tier + era | + +### How Many Distinct Visual Feels? + +Rough count: +- 8 base terrain types +- 10 heritage modifiers (functionally 6–8 meaningfully distinct groups) +- 4 economic tiers +- 3 eras +- 5 faction overlays (including none) + +**Strongly differentiated** (a player would name them differently): ~40–50. "Industrial greenhouse farm under Commission control," "subsistence Stone-heritage farmstead, old," "prosperous Tide-heritage coastal village, Era 2" — these feel like distinct places. + +**Meaningfully distinct** (a player would perceive as different): ~200+. The granularity at which heritage blend ratios differ (a 70/30 Frost/Iron vs. 50/50) is subtle but present. + +**The important bound:** A player going through 300 worlds encounters each combination at most a few times. The template library (D-025) remains the practical ceiling on variety, as Miri noted — but the modifier system ensures that two farmland districts with the same template read completely differently when one is a prosperous Vine-heritage settlement and the other is a failing Iron-heritage industrial operation. + +--- + +## 2. Organic Streets and Grid Breathing + +### Direct Answer to Ozzie's Demand + +Ozzie: "Tell me the grid can breathe." + +**It can. Here is what that means and what it requires visually.** + +There are three street/district layout modes. The visual grammar needs rules for all three. + +### Layout Mode 1: Grid Districts + +What we've been designing until now. Perpendicular streets, regular block footprints. Institutional, industrial, corporate, station interior. The grid communicates: this was planned, authority made this. + +Visual rules: as specified in Rounds 1 and 2. No changes. + +### Layout Mode 2: Relaxed Grid (Breathing Grid) + +Adjacent districts can have **different orientations** (rotated grid). A residential quarter at 15° off the main station grid reads as "built before the main plan was established, or outside its authority." The visual grammar's rules are **direction-agnostic** — corridor width minimums, LOS anchor intervals, zone palette assignments — all apply regardless of street orientation. The only additional rule needed: + +**Grid-rotation boundary treatment:** Where two districts with different orientations share an edge, the transition strip must handle the angular discontinuity. The floor tiles in the transition strip use the "angled transition" variant (see below). Street connections between the two grids happen through **diagonal connectors** (my Round 2 Technique 1), which are now read not as historical infrastructure but as the literal connection point between two urban grids of different orientation. + +Visual rule: the transition strip between a 0° grid and a 15° grid uses **the older era** of the two districts (as Tyre's transition block logic specifies), reinforcing the reading that one grid predates the other. + +### Layout Mode 3: Organic Districts + +Streets that curve. Block shapes that are L, T, or irregular polygon. This is what Ozzie is really asking for. + +**What "organic" means in a tile-based system:** + +The game uses a tile grid. True curves don't exist — but the *visual impression* of curves does. Organic streets are produced by a specific tile vocabulary. + +**Organic street visual rules:** + +1. **Curve mechanism**: Curves happen as 45° jogs in the street direction. A street that runs north for 6 tiles, then jogs northeast for 4 tiles, then returns north — from 16+ visual tiles away, this reads as a gentle curve. The jog is a visual feature, not a defect. + +2. **Angled wall tile variants**: Buildings on organic streets need wall faces at 45° and 135°. These are specific tile variants with the same zone palette material but a diagonal face. A building that presents a 45° corner to a diagonal street reads as organic — it was built to fit the street, not placed on a grid. + +3. **Street-width variability increases in organic districts**: In a grid district, street width might vary from 4–8 vt. In an organic district, it varies from 4–14 vt without feeling wrong. A street that opens into a piazza-width as it bends is the correct shape for an organic settlement. + +4. **Intersection treatment for non-90° intersections:** + - Obtuse intersection (>90°): the corner building uses a recessed setback, presenting a smooth face. The wider angle makes the building appear to "anchor" the intersection. + - Acute intersection (<90°): the corner building has a wedge-shaped setback or a chamfered corner (45° wall face). The wedge building is a classic organic settlement marker. + - These require "wedge corner" and "chamfered corner" floor tile variants. + +5. **Block boundaries in organic districts are building faces, not coordinates**: The visual grammar stops using block edges as generation hints. What the player reads as a "block" is defined by the street network on three or four sides. This block might be irregular in every dimension. The generator knows the block as a data structure; the player reads it as "the cluster of buildings between these streets." + +6. **Landmark density increase**: Without a grid, players lose orientation easily. Organic districts require **mandatory landmark placement every 12 visual tiles** (vs. 16 in grid districts). These landmarks are distinctive buildings (unusual rooftop shape, unusual material), significant trees in planet-side settings, or prominent corner objects. The navigator's landmarks replace the navigator's grid. + +7. **LOS anchor relaxation**: Organic irregularity IS the LOS anchor. A street that bends creates a sightline break at the bend. The max 16 vt anchor interval still applies, but in organic districts, the street geometry itself contributes to anchor count. A block with three irregular protrusions needs fewer interior anchors than a perfectly rectangular block. + +### Organic vs. Grid Visual Grammar Summary + +The core visual grammar rules **do not change** for organic districts. Zone palettes, lighting temperature, entity hierarchy, z-layer stack — all identical. What changes is: + +| Rule | Grid district | Organic district | +|------|---------------|-----------------| +| Corner tile variants | 90° only | 90°, 45°, 135°, chamfered | +| Landmark interval | 16 vt | 12 vt | +| Street width range | 4–8 vt | 4–14 vt | +| Block boundary | Coordinate-aligned | Face-defined by street network | +| LOS anchor source | Structural elements | Structural elements + street geometry | +| Setback variation | ±3 vt from baseline | ±6 vt (wider range) | + +The visual grammar is orientation-agnostic and curvature-extensible. This is the right answer to Ozzie. + +--- + +## 3. Vertical Visuals — Height in 2D Top-Down + +### The Problem + +In top-down view, a 50-floor skyscraper and a 3-floor office building have the same roof footprint. They're the same from above unless the visual grammar does work to differentiate them. + +### The Height Tier System + +I propose four height tiers, each with a defined visual signature: + +| Tier | Floors | Roof material complexity | Shadow length | Shadow hardness | +|------|--------|--------------------------|---------------|-----------------| +| S1 (Low-rise) | 1–3 | Simple parapet, zone material | 2–4 vt | Soft (gradient falloff) | +| S2 (Mid-rise) | 4–10 | HVAC units, ventilation stacks visible | 5–10 vt | Medium | +| S3 (High-rise) | 11–30 | Mechanical arrays, access structures, antenna | 12–20 vt | Hard (defined edge) | +| S4 (Extreme) | 30+ | Minimal — antenna farm, sensor cluster, landing area | 25–40 vt | Very hard | + +**The primary visual signal is shadow.** Shadow length scales with height. A 50-floor building casts a 30+ tile shadow. Players learn this grammar naturally — they see a long shadow and understand: something tall is nearby. + +### Shadow Direction + +Shadow direction is constant within a district and set at generation time. It represents the primary light source angle (the local star's position for planet-side; the orbital station's artificial sun angle for stations). + +**Rule:** Shadow falls in one consistent direction per district. This direction is recorded in the DistrictSkeleton (as a simple angle, 0–359°). All buildings in the district cast their shadow at that angle. This creates coherent lighting across the district. + +**Station exception:** In sealed station environments, the light strips run along the "ceiling" of the station, creating a diffuse downward illumination with no directional shadow. In station interiors, building height is communicated through **penumbra width** (ambient occlusion at the building base) rather than directional shadow. Taller station buildings have a wider soft-dark band at their ground level. + +### Shadow Visual Treatment + +Shadow tiles are **floor-layer overlays** (a slight darkening of the floor material beneath the shadow). They're not z=1 objects — they're a tint pass on the floor tiles in the shadow footprint. + +| Shadow type | Visual treatment | +|-------------|-----------------| +| S1 soft shadow | 2–4 tile gradient fade, max opacity 25% darkening | +| S2 medium shadow | Defined edge with 2-tile softening, max opacity 35% | +| S3 hard shadow | 1-tile softening, max opacity 45% | +| S4 extreme | No softening on long edge, max opacity 50% | + +**The shadow as gameplay element:** Hiding in a tall building's shadow reduces the caster's ambient lighting, which affects the fog-reveal properties and visual detectability. This is a natural gameplay consequence of the height communication system, not an engineered feature. + +### Rooftop Visual Vocabulary by Tier + +The roof of a building is visible from above. It should communicate what the building IS, not just how tall it is. + +**S1 (Low-rise):** Zone-palette roof material. A residential building has a simple flat roof or slight parapet. An industrial building has vent stacks (small z=4 objects). No structural complexity visible. + +**S2 (Mid-rise):** HVAC clusters (irregular z=4 groupings, dark metal, 2–4 vt wide), stairwell access structures (small box on one corner), possibly a loading bay indicator if commercial. The roof is busy in a functional way. + +**S3 (High-rise):** Complex mechanical array on z=4. Communications masts. Access platforms. If commercial: possible rooftop-level signage visible from above. If residential tower: rooftop garden elements (plant objects, seating). The roof reads as a separate zone with its own function. + +**S4 (Extreme — skyscraper):** Sparse but distinctive. The building's footprint at this height is functional rather than comfortable. Antenna farm or sensor cluster (thin vertical structures on z=4). Possibly a landing platform (helipad equivalent). The roof is visually minimal because at this height, only essential infrastructure is maintained. + +### Neural Insert Integration + +In **perception mode** (insert overlay, z=6), building heights are tagged. The insert displays a small elevation indicator adjacent to buildings — a bar graph scaled to height tier. This is the only time building height is explicitly labeled; outside perception mode, the player reads height through shadow and roof complexity. + +--- + +## 4. Destruction Visuals + +### The Visual Grammar of Destruction + +**Core principle:** Destruction does not create new palette colors. It corrupts the existing palette. A destroyed gate cluster area still uses gate cluster materials — just in their broken, exposed, or burned variants. This keeps destroyed areas visually coherent with their zone and prevents destruction from looking like a generic "brown rubble" overlay. + +### Destruction Stages + +**Stage 1 — Active (event in progress)** + +This stage is brief — active fire/explosion. Visual markers: +- Fire glow: `#ff6010` point lights at maximum intensity (far exceeding any zone palette fixture) +- Smoke layer (z=5, above fog): dark grey-brown particles, animated, at 70–90% opacity in affected area +- Structural instability indicator: flickering of any remaining fixture lights in affected area (the lighting phase-shifts, a sign of power disruption) + +**Stage 2 — Fresh Aftermath (0–48 in-game hours)** + +The smoke clears. The damage is visible. + +| Visual element | Specification | +|----------------|--------------| +| Scorched floor | Zone floor hex value, saturation reduced 60%, brightness reduced 15%, slight red-shift (add `#0c0402` to RGB) | +| Rubble objects | Zone wall/structure material, irregular shapes on z=2, 40% opacity — they're still there but partially blasted away | +| Debris scatter | Small fragments (1×1 tile z=2 objects) at radial distribution from blast center | +| Emergency barriers | Commission-grey `#787e84` temporary fencing on z=2; bright orange `#e05c20` site marking tape | +| Emergency lighting | Small PointLight2D, `#ff9040` warm orange (emergency lanterns), at 50% normal radius | +| Exposed infrastructure | Infrastructure layer becomes visible (see Section 6 — this is the same exposure mechanism as wall breach) | +| Missing overhead (z=4) | Roof elements removed in blast radius — sky/ambient fully visible | + +**Stage 3 — Stabilized** + +48h+ after event. No active emergency. The area is safe but not repaired. + +| Visual element | Specification | +|----------------|--------------| +| Cold rubble | Same rubble objects as Stage 2, now at 80% opacity (solidified) | +| Permanent barriers | Concrete block or heavy fencing (`#4a5058`) replacing emergency barriers | +| Dark zone | No fixture lighting in affected area — standard darkness | +| Reconstruction markers | Site markers (`#e05c20` orange, standard shapes) — 3–5 per affected block | + +**Stage 4 — Reconstruction** + +Active repair in progress. + +| Visual element | Specification | +|----------------|--------------| +| Construction scaffolding | Metal scaffolding on z=4, `#505458` grey, partial opacity 80% | +| Material mix | New-era floor tiles adjacent to old-era tiles — visible seam where old and new meet | +| Worker spawns | NPC spawn points in affected area (construction workers as NOBODY pattern) | +| Partial roof return | z=4 elements return to chunks where repair is complete | + +**Stage 5 — Healed Scar** + +Reconstruction complete but history visible. + +| Visual element | Specification | +|----------------|--------------| +| Era mismatch | The repaired area uses a newer era material than surroundings (same palette, visibly cleaner) | +| Floor seam | Subtle grout/joint pattern difference at the repair boundary | +| Memorial marker | Optional z=2 object if the event was significant — same specification as Settlement shrine | + +### The Destruction Palette (Summarized) + +These are the corruption values applied to any zone palette material: + +| Element | Modification | +|---------|-------------| +| Scorched floor | Desaturate 60%, darken 15%, add `#0c0402` red-tint | +| Charred structure | Base material, opacity 40–80% (varies by blast proximity) | +| Fire glow | `#ff6010` high intensity — NOT zone palette, always the same | +| Exposed infrastructure — power | `#c8b840` yellow-gold (standardized regardless of zone) | +| Exposed infrastructure — water/coolant | `#4888c8` mid blue | +| Exposed infrastructure — data/comm | `#b8b8b8` light grey, thin | +| Exposed structure core (rebar/beam) | `#3a3e42` dark metal | +| Open sky/void (where roof removed) | `#c8d8f0` sky ambient at 100% — deliberately bright | + +**The open sky tile** deserves special attention. It's the only floor-layer element in the game that glows brighter than the surrounding ambient (other than snow terrain). A destroyed building with its roof removed shows a bright sky-colored floor where the ceiling was. This is visually striking and communicates "open to sky" instantly. It's also a gameplay cue: line-of-sight changes dramatically in open-roofed areas. + +### Gas Explosion Specifically + +The lead's example: a gas explosion destroys part of a district. + +Gas explosions are hot, fast, and clean — no lingering fire. Visual signature: +1. Circular scorch pattern on floor tiles, centered on explosion point +2. Radial debris distribution (rubble objects scattered at 45°, 90°, 135°, etc. intervals for regularity) +3. Structural deformation: wall stub objects at irregular heights at the blast boundary +4. Exposed infrastructure: any walls within blast radius expose their infrastructure layer +5. Cleared zone: the explosion center is OPEN — no furniture, no objects, debris pushed outward not piled at center + +--- + +## 5. Horizon as Landmark (OQ-R3-E) + +### The Answer: Both Palette AND Landmark Reservation + +The ocean zone palette handles how the water looks and feels to be near. The landmark reservation system handles whether the discovery moment is guaranteed. + +**Rule: Coastal districts must include a "horizon view" landmark reservation** in the DistrictSkeleton. This is a mandatory constraint: one block on the coastal edge of the district must have an unobstructed view corridor of minimum 8 visual tiles from the street to the water. + +The landmark reservation is not a structure. It is **negative space** — an instruction to the generator that no building, tree, or z=4 element may be placed in a defined corridor oriented toward the water. The view must exist. + +### Why This Can't Be Left to the Palette + +Without an explicit reservation, the generator could place a warehouse right at the water's edge. The ocean palette would still be present beneath the warehouse floor. The player would walk through dock infrastructure, never see the water, and miss the moment entirely. + +The reservation guarantees the VIEW. The palette guarantees the FEELING when the view arrives. + +### The Visual Moment — Step by Step + +As the player moves from urban interior toward a coastal edge: + +**Distance 24+ vt from waterfront (still in district interior):** +Normal zone palette. Warm amber or institutional grey. Standard ceiling height (z=4 overhead present). + +**Distance 16–24 vt (transitional block begins):** +Transitional floor palette: zone floor gradually shifts toward coastal neutral. Lighting temperature begins to cool. The sound design (Inigo's domain) changes here — urban noise begins to yield. Buildings begin to lower (S1 height tier at transitional zone). + +**Distance 8–16 vt (coastal margin begins):** +T6 beach palette or dock palette begins. Timber, weathered material. The overhead layer (z=4) starts to thin. Dock structures, low bollards, the hardware of the water interface. Glimpses of water visible in gaps between structures. + +**Distance 0–8 vt (the view corridor):** +The overhead layer *opens*. Buildings stop. The z=4 layer is empty ahead. Full ambient sky exposure (if planet-side), or the station's curved hull visible overhead (if orbital coastal equivalent exists). + +**The moment:** +- LOS extends to 20+ visual tiles (limited only by fog shader) +- T5 coastal water floor tiles begin +- The animated specular reflection layer (`#c0c8d8` at 20% opacity) starts +- The ambient becomes cold deep blue +- No walls or overhead in the view direction +- A low z=2 element marks the spot: a railing, a bench, a bollard — not much, but enough to say "people come here to look" + +**What makes it hit:** The combination of LOS extension (the player's vision suddenly triples in the water direction) and ambient inversion (warmth to cold, enclosed to open) creates a sensory discontinuity. The player has been navigating by local landmarks and wall proximity. Suddenly neither applies. The rules of navigation change. + +**Station-interior equivalent (if applicable):** A station's "observation window" looking onto space works the same way. The overhead opens, the ambient goes deep black/star-field, and LOS extends to the window face. The landmark reservation mechanism is identical — just the palette differs. + +--- + +## 6. What's Behind the Wall + +### Three Cases, Three Visual Grammars + +When a player breaches a wall, one of three spatial conditions exists on the other side. The visual grammar needs to handle all three clearly. + +**Case 1: Adjacent Occupied Space (another room)** + +The most common case. The breach reveals the floor and contents of the adjacent room. + +Visual grammar: +- Breach indicator: a **ragged wall edge** tile variant — same zone material as the wall, but with a torn/blasted face. The opening is shown as a gap in the wall tile at the breach location. +- Debris pile: rubble objects (z=2, same material as wall) placed within 1–2 tiles of breach on both sides +- Revealed floor: z=1 floor tiles of the adjacent space become visible through the opening (they were there all along, obscured by the wall's tile width) +- LOS extension: the player's sightline now passes through the breach at reduced cone width (the opening is narrower than a door) +- Objects visible: any furniture or objects in the adjacent space become visible if within the narrowed LOS cone + +**Case 2: Interior Cavity (wall contains infrastructure)** + +Large buildings (4+ visual tiles wide as structural dimension) have internal wall cavities. A breach into a cavity reveals the building's "nervous system." + +This is the most visually interesting case. + +Visual grammar: +- The cavity is 1–2 tiles wide (enough to be visible through the breach, not enough to enter) +- Infrastructure layer visible: **color-coded conduits and pipes** at z=1.5 (between floor and furniture layers in the z-stack): + +| Infrastructure type | Color | Width | +|---------------------|-------|-------| +| Power conduit | `#c8b840` yellow-gold | 1 tile | +| Water/coolant pipe | `#4888c8` mid blue | 1–2 tiles | +| Data/comm line | `#b8b8b8` light grey | 0.5 tile (thin) | +| Structural beam | `#3a3e42` dark metal | 2–3 tiles | +| Ventilation | `#585e60` dark grey, wider | 2–4 tiles | + +- Structural material cross-section visible: the interior face of the wall shows its core material (darker than the face material; `#181c20` dark stone or `#20242a` dark composite) +- Era indicates infrastructure density: + - Era 1: minimal (power only, stone structure) + - Era 2: mixed (power + water + comm lines) + - Era 3: full bundle (all infrastructure types, more densely bundled) + +**Case 3: Building Perimeter Breach (exterior wall)** + +The player has breached from inside a building to outside, or vice versa. + +Visual grammar: +- The outside space becomes visible through the breach (street, alley, exterior zone) +- The exterior face of the wall was the visible face; the breach now shows the building's interior face (different material tone — slightly warmer/lighter for interior finishing versus exterior facing) +- A **floor underwall strip**: a 1-tile-wide strip of floor tile that was hidden by the wall's footprint becomes visible. This strip often contains pushed-against objects: crates, gear, things stored against the wall. If the building has hidden objects near that wall, they're now partially revealed. +- If something was being deliberately hidden against this wall, the breach may expose it visually before the player has crossed the threshold + +### The Wall Infrastructure Layer — A Generator Rule + +**Rule:** Every building with a structural footprint ≥4 vt in any dimension has a wall infrastructure layer generated at block planning time. This layer is: +- Invisible during normal gameplay (hidden by wall tile rendering) +- Visible when the wall tile is breached (z=1.5 reveal) +- Consistent with the building's era tag (controls which infrastructure types are present) +- Consistent with the zone (a residential building has water and power; a data center has redundant comm lines) + +The infrastructure layer is pre-generated but not pre-rendered. It exists in the ChunkData but only draws when the corresponding wall tile is in a "breached" state. Generation cost is trivial (it's a pattern draw, not procedural generation). + +### The Visual Grammar Principle: Walls Are Not Void + +The key design principle behind this section: **walls are not empty.** They have thickness, content, and history. When a player breaches a wall, they don't find nothing. They find one of three things: another room, the building's infrastructure, or the outside. Any of those is a discovery. + +The visual grammar should make that discovery feel earned — the ragged edge, the revealed pipes, the sudden view of the street or the next room. A breach is a spatial event. It changes what the player can see and where they can go. + +This connects to Ozzie's "what I need is a place I'm not supposed to be." The place you're not supposed to be is not always a special room. Sometimes it's the space inside the wall. The infrastructure cavity. The under-floor route. The generator produces these spaces naturally as a consequence of how buildings are built, not as marked secrets. + +--- + +## 7. Additional: OQ-R3-A — Can the Grid Breathe? + +I addressed organic streets in Section 2, but I want to give Tyre a concrete visual grammar answer for what needs to change in the data structures to support grid rotation. + +The visual grammar is already orientation-agnostic. Every rule I've written specifies distances and relationships (16 vt LOS interval, 4 vt minimum corridor width, 2-tile softening on shadows) rather than absolute directions. Rotating a grid district by 15° and applying the same rules produces a valid, coherent space. + +What the visual grammar requires from Tyre's data structures: +1. A `grid_orientation: f32` field on `DistrictSkeleton` (angle in degrees, 0 = standard N/S/E/W alignment) +2. That angle propagates to block and chunk generation as a rotation parameter for template stamping +3. The transition strip between two differently-oriented grids uses diagonal connector tile vocabulary (defined in my Round 2 Technique 1) at the rotation seam + +There is one additional visual rule needed for multi-orientation districts: + +**Rule:** At the boundary between two districts with different grid orientations, the transition strip must contain at least one **angular landmark** — a building or structure that reads as occupying the angular seam. This is typically a triangular or trapezoidal building footprint placed at the angular intersection. It communicates "this is where the two grids meet" through shape rather than explicit labeling. + +The diagonal connector palette (maintenance corridor neutral `#181818`) applies to these angular landmarks regardless of zone, reinforcing their reading as "infrastructure that navigates between two spatial systems." + +**My recommendation for Tyre:** `grid_orientation` is a simple f32 on DistrictSkeleton. Template stamping with a rotation matrix is standard geometry. This is lower implementation complexity than most of what's been proposed. Ozzie should get her grid-breathing answer in Round 3 rather than deferred. + +--- + +## Summary + +**Palette granularity:** The modifier system (3 axes: heritage root → material character, economic tier → condition/density, era → material generation) layered on 8 base terrain types produces 40–50 strongly differentiated visual feels and 200+ meaningfully distinct combinations. Industrial farming and rustic farming differ at the base palette level (T1 vs. T2 for ambient regime) AND at the heritage/economic modifiers. The modifier system is the correct architecture — not more fixed palettes. + +**Organic streets / grid breathing:** The visual grammar is orientation-agnostic. Organic districts require: 45° and angled wall tile variants, higher landmark density (12 vt vs. 16 vt interval), wider street width range (4–14 vt), and face-defined blocks. Grid rotation requires `grid_orientation` on DistrictSkeleton and angular landmarks at rotation seams. Ozzie's demand is satisfiable without new visual grammar rules — just additional tile variants and a data structure field. + +**Vertical visuals:** Four height tiers (S1–S4). Primary signal is shadow length (2–40 vt), which scales with height and creates a natural gameplay shadow system. Rooftop material complexity increases with height. In station interiors, penumbra width replaces directional shadow. Neural insert perception mode displays explicit height tags. + +**Destruction:** Corruption-based palette system — no new colors, existing materials in broken/scorched/exposed states. Five destruction stages from active fire to healed scar. The open-sky tile (bright `#c8d8f0` at 100%) is the visual flag for "roof removed." Infrastructure exposure uses standardized color codes that apply regardless of zone, making piping and conduits identifiable anywhere in the game. + +**Horizon as landmark:** Both palette and explicit landmark reservation required. The coastal district DistrictSkeleton must include a mandatory negative-space view corridor (8+ vt unobstructed) at the waterfront. The discovery moment is produced by the combination of LOS extension, animated specular reflection, ambient inversion (warm → cold), and the absence of overhead obstruction. + +**What's behind the wall:** Three cases — adjacent room (floor and contents visible), infrastructure cavity (color-coded pipes and conduits at z=1.5), exterior breach (outside revealed, floor underwall strip exposed). The wall infrastructure layer is generated at block planning time, invisible until breach, consistent with building era. Walls are not void — they're discoveries waiting to be opened. + +**OQ-R3-A addendum:** `grid_orientation: f32` on DistrictSkeleton, angular landmarks at rotation seams. This is buildable. Recommend locking it in Round 3. + +--- + +*Araminta — Round 3 complete. Standing by for convergence decisions.* diff --git a/docs/workshops/generator-architecture/araminta-round4.md b/docs/workshops/generator-architecture/araminta-round4.md new file mode 100644 index 000000000..80d5ce259 --- /dev/null +++ b/docs/workshops/generator-architecture/araminta-round4.md @@ -0,0 +1,431 @@ +# Generator Architecture Workshop — Round 4: Araminta +## Heritage Grammar Authoring, Vessel Visual Grammar, Rooftop Bar Clause, D-Record Sign-off + +**Author:** Araminta (Visual Designer) +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (Ticket #562) +**Responding to:** Round 3 notes (Qatux), OQ-R4-D, vessel architecture, lead directives +**Status:** Round 4 — final convergence + +--- + +## Opening: Four Items, All Closeable + +This is my shortest round by design. Three of my four assignments are new specification work; the fourth is a review pass. I'm not going to repeat the grammar I established in Rounds 1–3. I'll reference it and move forward. + +--- + +## 1. OQ-R4-D: Heritage Grammar Overlay — Authoring Workflow and Runtime Pipeline + +### The Core Question + +Miri asks: per-heritage modifier objects that chunk fill applies, or lookup tables within terrain palette assets? + +**Neither exclusively. The right answer is data-driven modifier objects — structured like per-heritage objects (typed, blendable) but stored as content files (TOML/YAML), not hardcoded in the palette assets or the code.** + +Here's why each pure option fails: +- **Lookup tables in palette assets**: Heritage grammar gets duplicated across every terrain type that uses it. A Vine heritage modifier rule gets written once for farmland, again for wilderness, again for coastal, again for urban. Inconsistency accumulates. Updating "all Vine farms" means touching every terrain palette. +- **Hardcoded per-heritage modifier objects**: Changing a modifier or adding a new heritage variant requires a code change and rebuild. + +The hybrid: `HeritageModifier` structs defined in TOML/YAML content files, loaded at startup, blended at chunk fill time. + +### The Data Structures + +**Content file (TOML — designer-authored, one file per heritage root):** + +```toml +# content/heritage/vine.toml +[heritage_modifier] +root = "Vine" +applicable_terrain = ["T1_farmland", "T2_industrial_farm", "T3_wilderness", + "T4_grassland", "T6_beach", "T8_desert", "Urban", "Orbital"] +# Modifiers below are overrides/additions to the base terrain palette defaults. + +[floor] +variant_preference = ["worn_path", "mossy_edge", "pressed_earth"] +# "worn_path" is a floor tile variant in the terrain asset library + +[objects] +object_set = "vine_heritage_outdoor" +# Identifies a named set in the object asset library. +# The object set contains: trellises, planters, outdoor seating clusters, +# seasonal decoration markers, communal fire infrastructure, door frame plants. +arrangement = "organic_cluster" +# "organic_cluster" is an arrangement algorithm identifier, not hardcoded. +# Valid values: grid, organic_cluster, edge_accent, radial, scattered + +[overhead] +density_factor = 0.65 # 0.0-1.0 relative to terrain type's max overhead budget +character = "personal_organic" +# Valid values: institutional, personal_organic, personal_functional, seasonal, sparse, none + +[gathering] +space_probability = 0.40 # chance of a gathering space in any outdoor quarter + +[lighting] +temperature_adjustment_k = 800 # Kelvin added to zone baseline (positive = warmer) +fixture_character = "personal" # personal | functional | institutional | absent + +[boundaries] +fence_type = "trellis_wood" # references fence asset set +boundary_height = "low" # low | medium | high | wall + +[social] +# How this heritage root modifies the social texture visible in the space +exterior_welcome_signal = true # buildings present welcoming elements toward the street +privacy_orientation = "outward" # outward | inward | neutral +accumulation_character = "warm_organic" # warm_organic | functional | austere | ordered +``` + +A designer writes this file. No code changes for new heritage variants or adjustments. + +**The full 10-root modifier table, summarized as authoring targets:** + +| Root | Object set | Arrangement | Overhead character | Temp adj. (K) | Gathering prob. | Boundary | Exterior signal | +|------|-----------|-------------|-------------------|--------------|----------------|----------|----------------| +| Frost | `frost_heritage` | `grid` | `sparse` | -600 | 0.10 | `low_wire` | false | +| Vine | `vine_heritage` | `organic_cluster` | `personal_organic` | +800 | 0.40 | `trellis_wood` | true | +| Stone | `stone_heritage` | `edge_accent` | `personal_functional` | 0 | 0.25 | `permanent_stone` | false | +| Tide | `tide_heritage` | `radial` | `seasonal` | +200 | 0.55 | `low_open` | true | +| Iron | `iron_heritage` | `grid` | `institutional` | -200 | 0.30 | `shared_infra` | false | +| Dust | `dust_heritage` | `scattered` | `functional` | 0 | 0.20 | `weatherproof` | false | +| Spice | `spice_heritage` | `zone_divided` | `personal_organic` | +400 | 0.25 | `medium_defined` | true | +| Salt | `salt_heritage` | `grid` | `sparse` | -100 | 0.15 | `functional` | false | +| Arc | `arc_heritage` | `grid` | `functional` | -100 | 0.30 | `labeled_markers` | false | +| Jade | `jade_heritage` | `edge_accent` | `personal_organic` | +100 | 0.20 | `refined_low` | true | + +**Arrangement algorithm summary:** +- `grid`: Evenly spaced, parallel orientations. Objects align to grid. +- `organic_cluster`: Grouped irregular spacing, varied orientations. Objects face each other. +- `edge_accent`: Objects placed at spatial boundaries (building edges, path margins, corners). +- `radial`: Objects arranged relative to a central gathering point. +- `scattered`: Low-correlation positions, minimal clustering. +- `zone_divided`: Objects define distinct spatial sub-zones within the quarter. + +### Runtime Pipeline — What Chunk Fill Looks Up + +``` +Chunk fill receives: + ChunkFillSpec { + zone_id, + era, + chunk_seed, + society_profile: SocietyProfileRef ← includes heritage blend + } + +Step 1: Load base terrain palette + palette = load_base_palette(zone_id.terrain_type) + +Step 2: Blend heritage modifiers + For each (root, weight) in society_profile.heritage.roots: + modifier = load_heritage_modifier(root) + blended = blend_modifiers(modifiers_with_weights) + // Blending rules: + // Continuous values (temperature_adjustment_k, density_factor, gathering_probability): + // weighted average + // Discrete values (object_set, arrangement, fence_type): + // weighted probabilistic selection (dominant root wins most of the time) + // Boolean values (exterior_welcome_signal): + // weighted probability (60% Frost / 40% Vine: 40% chance of welcome signal) + +Step 3: Apply blended modifier to palette + working_palette = apply_modifier(palette, blended) + +Step 4: Fill quarter using working_palette + era + chunk_seed + (existing fill logic — place floor tiles, objects, overhead elements, lighting) +``` + +**Blend example: 60% Frost / 40% Vine farmland** +- `temperature_adjustment_k`: (0.6 × -600) + (0.4 × +800) = -360 + 320 = -40K (barely cooler than baseline — the two largely cancel) +- `density_factor`: (0.6 × sparse_default) + (0.4 × 0.65) = moderate overhead +- `arrangement`: Frost wins probabilistically (60%). Grid arrangement, but some organic cluster elements from Vine's 40% share appear in the overhead layer. +- `exterior_welcome_signal`: 40% chance — the farm presents a slightly inviting exterior, but the Frost efficiency still dominates the floor plan. + +**The output is a farm that's functional and ordered (Frost dominant) but with a few organic touches — a trellis here, an informal seating corner there — that tell you Vine heritage is also present.** + +### What an Artist/Designer Authors + +Three asset types feed the heritage modifier system: + +**1. Object sets** (artist-authored, tagged by heritage root) +An object set is a named collection of placeable objects in the asset library. The artist creates objects appropriate to each heritage root's aesthetic and tags them. Adding new Vine heritage objects to the base game means adding them to the `vine_heritage_outdoor` object set — no modifier file change needed. + +**2. Heritage modifier files** (designer-authored TOML, one per root) +The ten files described above. A designer can adjust behavior by editing these files. No code changes. If a heritage modifier is added (future expansion), one new TOML file is all that's needed. + +**3. Terrain palette base files** (artist-authored, one per terrain type) +The base terrain palette with default object density, lighting, and floor tiles. The heritage modifier overrides these defaults — anything not overridden uses the terrain palette default. This means a terrain type only needs to specify its own defaults; heritage grammar is applied on top. + +### The One Design Rule That Must Be Stated Explicitly + +**Rule:** The heritage modifier is applied at chunk fill time (Phase 2), not at district skeleton time (Phase 1). The DistrictSkeleton carries `society_profile: SocietyProfileRef`. Phase 2 chunk fill reads the heritage blend from the profile and applies the modifier. This means the heritage grammar is visible in the tiles but never stored as a structural generator decision — it's a visual expression, not a spatial constraint. + +**Exception:** `gathering_probability` from the heritage modifier can influence Phase 1 quarter pre-assignment (whether a quarter is pre-assigned as "outdoor gathering space"). If so, the heritage modifier must be partially evaluated at block planning time for that specific parameter. All other modifier parameters apply at Phase 2 only. + +--- + +## 2. Vessel Visual Grammar + +### The Answer: Modifiers, Not New Base Palettes + +Vessels use existing zone palettes. What distinguishes a vessel visually is not a different material vocabulary — it's a different **spatial grammar**. The material inside a luxury cabin is the same amber-warm bar palette. The material inside a cargo hold is the same maintenance-grey. What's different is the envelope, the proportion, and the bounded-environment signals. + +**Rule: vessels are existing-palette spaces with five additional visual grammar rules.** + +### The Five Vessel Visual Grammar Rules + +**Rule V-1: Exterior hull is vessel-identity material, not zone palette.** + +The outer hull/skin of any vessel uses a specific material that identifies the vessel as a vessel: +- Spacecraft: `#2a2e32` (dark cold metal, Era-appropriate surface texture — Era 1 = riveted plate, Era 2 = welded panels, Era 3 = smooth composite) +- Trains: livery color applied to the exterior — muted version of the operating company's identity color, within saturation rules +- Ships/boats: `#2a2820` (weathered dark hull, salt-worn) + +The exterior hull material applies only to the outermost layer visible from outside. Interior spaces use zone palettes normally. + +**Rule V-2: Window tiles provide exterior context.** + +Where a vessel has windows (train windows, porthole, cockpit view), the window tile is a special element: +- On the floor layer, the window gap shows a z=0 background buffer — a scrolling or static exterior visual +- In transit: the exterior buffer shows appropriate passing environment (stars, landscape, water) +- Docked: the exterior buffer shows the dock environment (warehouse wall, station hull) +- The window frame is vessel-hull material, the opening is the contextual reveal + +**Rule V-3: Vessel spaces use a compression modifier.** + +The same zone palette, but proportionally tighter: +- Overhead height budget reduced by 30% (same z=4 elements, but lower effective ceiling) +- Object density increased within the same floor area (the space is used intensively) +- Corridor minimums still apply (V-05 rules), but vessel corridors bias toward minimum width + +This compression modifier communicates "bounded mobile environment" without requiring new palettes. + +**Rule V-4: Section transitions use vessel-identity threshold elements.** + +Where one vessel section (train car, ship compartment) connects to another, the threshold is: +- Door frame in vessel-hull material (not zone palette) +- A visible width reduction at the threshold (the door opening is narrower than the corridor) +- The threshold material is constant across the vessel — it marks every internal boundary + +This makes internal navigation feel like moving through a vessel, not a building. + +**Rule V-5: Class stratification is expressed through proportion, not palette.** + +In a passenger vessel with multiple service classes: +- First class: higher overhead clearance (+20% overhead budget), wider aisles (+2 vt), warmer lighting temperature (+400K) +- Standard class: base compression modifier +- Economy/crew: maximum compression (-10% further from standard), colder lighting (-200K) + +Same palette. Different proportions. A player who knows the grammar can read service class instantly from spatial feel. + +### Vessel-Specific Surface Cases + +**Train cars (BoundedLinear):** Each car is a short, rectangular interior space. The key visual grammar element is the **visible car sequence** — the window at each car end shows the next car, creating a visual depth cue (you can see through multiple cars from the right position). This is implemented as a window tile facing the coupling direction showing the next car's interior. + +**Spaceship corridors (InterSystem):** No exterior context visible during transit — black void or star-field through windows. Artificial lighting dominant (no ambient from outside). Institutional palette for working spaces; residential palette for crew quarters. The compression modifier is strongest here — corridors are minimal-width, spaces are used entirely. + +**Ship cabins (BoundedMaritime):** Port-side windows show harbor environment when docked; ocean/sky when at sea. The marine weathering texture (visible on hull material) is the primary "you're on a ship" signal. The rocking motion (client-side animation) is Inigo's domain, not mine. + +--- + +## 3. Rooftop Bar Clause — Public vs. Restricted Rooftop Visual Grammar + +### The Tension and Its Resolution + +Gestalt's guarantee: every tall structure (z_band_count ≥ 3) must have a roof zone classified `Insider` or `BreachOnly` accessible by non-obvious route. + +The lead wants rooftop bars: public social destinations on tall buildings. + +**These are not in conflict if we amend the guarantee correctly.** + +**Amended guarantee:** Every tall structure must have a roof zone that constitutes a *discovery* — something worth reaching the top for. That discovery can be: +- **A public destination** (rooftop bar, garden, observation deck) — accessed via obvious route, classified `Public` or `Semi-Public` +- **A restricted discovery** (operational rooftop, private access) — accessed via non-obvious route, classified `Insider` or `BreachOnly` + +Both satisfy the intent: the top of the building is not just mechanical infrastructure. The access tier varies; the discovery does not. + +**Structural rule:** A building can have both. A skyscraper with a public rooftop bar at z_band top AND a restricted antenna/comms level above it satisfies both playstyle needs. The rooftop bar is the destination for social/tycoon/investigation players. The above-the-bar maintenance level is the discovery for the breach/assassin player. + +### Rooftop Bar Visual Grammar (S3 and S4 Height Tiers) + +A rooftop bar at S3 (11-30 floors) or S4 (30+) is visible from above and must communicate "social destination, open to arrival." + +**Floor material:** Warm pavers or composite decking — lighter and warmer than the industrial-default rooftop. Not the zone palette floor — this is a curated outdoor surface. Hex: `#2a2018` (warm dark slate/composite), visually distinct from the `#181c22` cold building roof tiles. + +**Perimeter barrier (z=2):** A designed railing or low wall around the outdoor area — thin (1-tile), warm material (matching the bar zone palette's furniture material). The barrier communicates "this is a social edge, not a fall hazard." Visually distinguishable from the functional parapet of an institutional rooftop by material and continuity (it's a designed feature, not a structural necessity). + +**Seating clusters (z=2):** Small furniture objects — tables and chairs at human scale. At S3/S4 heights, these are small enough that from the ground-level view they're just warm texture. From the adjacent building observation deck or elevator approach, they're readable as social furniture. High-value visual cue: the seat arrangement creates clusters (2-4 chairs around a table), not rows. + +**Service structure (z=2):** A bar counter, or a small pavilion structure housing the service area. This is the anchor of the rooftop social space — the visible sign that this was designed to serve people, not just stand on. + +**Lighting signature (z=4):** Warm pendant fixtures or string lights — the most visually distinctive element. Temperature `#f0b840` (amber, the bar zone palette) with 80% radius of a ground-floor bar fixture. From a nearby elevated position (adjacent building, observation floor), this warm lighting cluster at height is readable as "social gathering above." + +**Access indicator:** A visible stairhouse or elevator shaft entrance at z=2/4 — not hidden, not service-hatch scale. The access is designed as part of the social space, not an afterthought. + +### Restricted Rooftop Visual Grammar (Contrast) + +The restricted rooftop must read as *not-welcome* at a glance: + +**Floor material:** Zone-standard cold industrial roof tile (`#181c22` blue-grey). No warm deviation. + +**Perimeter barrier (z=2):** Functional parapet — same material as building structure, no warmth, no design intent beyond preventing falls. Or absent (exposed edge with safety mesh). + +**z=2 objects:** Mechanical equipment (HVAC clusters, antenna bases, sensor arrays, junction boxes). These objects face away from a human observer — they're not oriented to be seen, they're oriented to function. + +**Lighting (z=4):** Cold work lights only, if present — or absent. Temperature `#c0d0e0` (cold), directed down. No ambient warmth. + +**Access indicator:** Service hatch, maintenance door — small, flush with the floor, visually minimized. Or a locked stairway access door marked with institutional signage. + +### The Visual Grammar Test + +A player approaching a tall building from the street should be able to read, from the roof's visible lighting signature at night: +- **Warm amber cluster, designed railing visible** = rooftop bar / social destination +- **Cold/dark, mechanical silhouette** = restricted / breach route + +At S4 heights, the warm glow of a rooftop bar is visible from 12+ tiles away as a warm point-light cluster elevated against the dark building mass. That visual signal is navigation — a player who wants a social destination looks for the warm light at height. + +--- + +## 4. Final Visual Sign-off — 12 D-Ready Items + +Reading through each item and flagging wording, additions, or changes needed before the D-record is written. + +--- + +**D-READY-1: DistrictLayoutMode — Grid and Organic Support** + +✓ Correctly captured. Visual grammar confirmed: 45° wall variants, 12 vt landmark interval, 4–14 vt street width range, face-defined blocks. + +**Wording flag:** The D-record must state the 45° rotation cap as a **hard technical constraint**, not a design preference. Language: "Maximum block rotation is ±45° from district grid orientation. This limit is non-negotiable — beyond 45°, tile-based pathfinding produces unacceptable movement artifacts. Organic districts produce the visual impression of curved streets through angular jogs, not smooth curves." + +--- + +**D-READY-2: Guarantee Tier System — Universal / Full-Only / Conditional** + +✓ Visual grammar correctly captured. The horizon view corridor is confirmed as a Tier 2 guarantee for coastal districts. + +**Wording addition needed:** Add the rooftop destination clause (Section 3 above) as a Tier 2 guarantee: "Every tall structure (z_band_count ≥ 3) must contain a roof zone that constitutes a discovery — either a public destination (Public/Semi-Public access tier) or a restricted discovery (Insider/BreachOnly access tier, accessible by non-obvious route). Both types are valid; neither is mandatory over the other." + +--- + +**D-READY-3: TrianglePurpose Enum** + +No visual grammar implications. ✓ No changes. + +--- + +**D-READY-4: WallBackside / TileBehindState** + +✓ Three visual cases confirmed: adjacent room, infrastructure cavity, perimeter breach. + +**Wording addition:** "Infrastructure cavity contents are Era-tagged: Era 1 = power conduit only; Era 2 = power + water/coolant + comm lines; Era 3 = full bundle (all types, more densely bundled). The infrastructure color codes are standardized across all zones — power `#c8b840`, water/coolant `#4888c8`, comm/data `#b8b8b8`, structural beam `#3a3e42`. These colors apply regardless of zone palette." + +--- + +**D-READY-5: Dynamic Modification via Overlay** + +✓ Five-stage destruction visual correctly captured. + +**Addition needed — trauma event → visual stage mapping:** + +The D-record should include the mapping from `ModificationType::TraumaEvent` subtypes to starting destruction stage: + +| Trauma event subtype | Starting visual stage | Scope | +|----------------------|-----------------------|-------| +| `PhysicalDestruction` | Stage 2 (Fresh Aftermath), decaying to Stage 3 over game-time | Area or district | +| `ViolenceEvent` | Stage 2 (limited area — 1–4 chunks), stabilizes to Stage 3 quickly | Local | +| `EconomicDisruption` | No destruction stage — Economic Stress quarter modifier applied instead | District-wide | +| `PoliticalShock` | No destruction stage — Faction presence modifier (overlay) | District-wide | +| `MigrationShock` | No destruction stage — Settlement vs. Economic Stress balance shifts | District-wide | + +**The rule:** Destruction stages apply only to events that physically alter structures. Economic/political/migration trauma is expressed through the quarter fill modifier system (D-READY-6), not through destruction stages. + +--- + +**D-READY-6: ZonePalette Modifier System** + +✓ 8 base terrain types, 3 modifier axes confirmed. + +**Wording addition needed:** Explicitly name T1 and T2 as the two farmland base palettes addressing the industrial/rustic distinction: + +"T1 (Temperate Farmland): warm organic ground ambient, natural lighting regime. T2 (Industrial/Greenhouse Farmland): cool grey-green ambient, artificial lighting regime. The ambient regime difference (natural vs. artificial) is the primary visual distinction between rustic and industrial farming at the base palette level. Heritage root modifiers further differentiate them at the grammar level." + +--- + +**D-READY-7: Horizon View Corridor as Coastal Guarantee** + +✓ Fully confirmed. ≥8 vt unobstructed view corridor. Mandatory negative space reservation. + +**Wording clarification:** The view corridor is **negative space** — an instruction to not place blocking structures, not a placed object. "The generator reserves a view corridor of minimum 8 visual tiles from the nearest public street to the water's edge. No building, tree, or z=4 element may occupy this corridor. A low z=2 element (railing, bench, bollard) marks the waterfront point as a designed viewing location." + +--- + +**D-READY-8: Assassin Lens Spatial Guarantees (A-1 through A-4)** + +✓ Correctly captured. The spatial guarantees don't require new visual elements — existing grammar serves the assassin. + +**Wording note:** Guarantee A-1 (Elevated Vantage) requires clarification about what "clear LOS cone" means visually. Add: "An elevated vantage position must have a direct sightline — no z=4 overhead elements within the LOS cone between the vantage point and the Traffic Chokepoint. The generator ensures this by tagging the LOS corridor as an overhead-clear zone during block planning." + +--- + +**D-READY-9: Heritage Grammar Overlay for Non-Urban Palettes** + +**Status: Not yet lockable — this round provides the authoring workflow that was missing.** + +See Section 1 above for the full specification. The D-record needs: +1. The `HeritageModifier` struct definition (from Section 1) +2. The 10-root modifier table (from Section 1) +3. The authoring workflow description (from Section 1) +4. The rule: heritage modifier applied at Phase 2 chunk fill, with one exception (gathering_probability for Phase 1 pre-assignment) + +**With Section 1 above, this item is now lockable.** + +--- + +**D-READY-10: Non-Urban Informal Zone Typology** + +✓ Correctly captured. Miri's three types resolved. + +**Addition — visual grammar for each informal zone type:** + +The D-record should include how each type reads visually: + +| Informal zone type | Visual signature | +|-------------------|-----------------| +| `social_permission` | Normal zone palette, but gathering infrastructure present (covered space, seating). The space looks designed for use, not abandoned. The signal is the gathering element, not absence of official markers. | +| `physical_distance` | Sparse objects, low overhead. Floor tile: terrain-appropriate but less maintained (no worn path markers — the path is made by walking, not cleared). The isolation IS the visual — this space has no design attention. | +| `utilitarian_cover` | Functional work objects. Normal zone palette. The informal zone reads as work space — the cover story is the visual appearance. There is no obvious sign that this space serves unofficial purposes; the player must infer from context. | + +--- + +**D-READY-11: Vertical Scale Architecture** + +✓ Height tier system (S1–S4), shadow lengths, rooftop vocabulary all confirmed. + +**Addition for the D-record:** The Rooftop Bar Clause from Section 3 above should be incorporated as a sub-rule of the vertical scale architecture decision. Specifically: "The roof zone at tier S3+ must be assigned a social character during block planning: `PublicDestination | RestrictedDiscovery`. This character determines the visual grammar applied at chunk fill time." + +--- + +**D-READY-12: Trauma Events as EraModification Subtypes** + +✓ Correctly captured from Miri's work. + +**Addition — visual stage mapping already provided in D-READY-5 wording above.** The two D-records cross-reference. + +**One wording clarification:** The "active modification state" that decays toward baseline — this decay is visual. As time passes (in-game hours/days), the destruction stage advances from Stage 2 → Stage 3 → Stage 4 → Stage 5. The decay rate is heritage-root-dependent (Frost: faster visible recovery; Tide: faster social recovery; Arc: lingers institutionally). This decay schedule should be in the D-record as a visual parameter: `trauma_visual_decay_rate: slow | medium | fast` per heritage root, defaulting to `medium`. + +--- + +## Summary + +**OQ-R4-D (Heritage Grammar Overlay):** Resolved. Authoring workflow: ten TOML modifier files (one per heritage root), each specifying object sets, arrangement algorithm, overhead character, lighting temperature adjustment, and gathering probability. Runtime: blend modifiers at chunk fill time by heritage weight (continuous values: weighted average; discrete values: weighted probabilistic selection). Phase 1 exception: gathering_probability evaluated at block planning for quarter pre-assignment. Item D-READY-9 is now lockable. + +**Vessel Visual Grammar:** Vessels use existing zone palettes with five additional rules: (1) exterior hull is vessel-identity material; (2) window tiles reveal exterior context; (3) compression modifier tightens proportions; (4) section transitions use vessel-identity threshold elements; (5) service class is expressed through proportion, not palette change. No new base palettes required. + +**Rooftop Bar Clause:** Gestalt's guarantee amended — "discovery zone at the top" replaces "Insider/BreachOnly" as the mandatory requirement. Public rooftop destinations (rooftop bar, garden) satisfy the guarantee equally with restricted ones. Visual distinction: warm amber lighting + social furniture (public) vs. cold mechanical silhouette (restricted). Both readable at a glance from adjacent elevation. + +**D-record sign-off:** All 12 items reviewed. Five wording additions/clarifications flagged. D-READY-9 was the only item not previously lockable — it is now, with Section 1 providing the missing authoring workflow. The trauma → visual stage mapping (D-READY-5/12) is new and must be included in the D-records. + +--- + +*Araminta — Round 4 complete. All four assignments closed. Standing by for D-record production.* diff --git a/docs/workshops/generator-architecture/araminta-round5.md b/docs/workshops/generator-architecture/araminta-round5.md new file mode 100644 index 000000000..aacc645d4 --- /dev/null +++ b/docs/workshops/generator-architecture/araminta-round5.md @@ -0,0 +1,116 @@ +# Generator Architecture Workshop — Round 5 (Final Review): Araminta + +**Role:** Visual Designer +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## Sign-Off + +The outcomes document accurately captures the visual design domain. All five wording additions and clarifications I flagged in Round 4 are incorporated correctly: + +- **D-READY-1**: 45° cap stated as "non-negotiable" hard technical constraint — correct wording, correct framing. +- **D-READY-2**: Rooftop Discovery Zone listed as Tier 2 Full-complexity guarantee — correct. +- **D-READY-4**: Era-tagged infrastructure color codes present (`#c8b840` / `#4888c8` / `#b8b8b8`) — correct. +- **D-READY-5**: Trauma event → visual stage mapping present. Physical events → Stage 2, economic/political/migration → quarter fill modifier. Correct. +- **D-READY-6**: T1 (warm organic, natural lighting) / T2 (cool grey-green, artificial lighting) explicitly distinct — correct. +- **D-READY-7**: "Negative-space reservation" framing — correct. +- **D-READY-9**: Data-driven TOML modifier files (10 per heritage root), Phase 2 chunk fill blend, Phase 1 gathering_probability exception, authoring domain separation (Miri = spatial grammar, Araminta = visual expression + TOML files) — all correct. +- **D-READY-10**: References `araminta-round4.md` for visual grammar per informal zone type — acceptable approach, avoids duplication. +- **D-READY-11**: Rooftop Bar Clause present. Discovery layer mandatory in both configs. Correct. +- **D-READY-12**: `trauma_visual_decay_rate: slow | medium | fast` per heritage root, default medium — correct. +- **Q-NNN-e**: ObjectTag co-maintenance flagged as open question — correct. + +I also confirm Ozzie's correction on D-READY-11: "Heritage root determines which config is assigned" is wrong and I should have been more precise in my R4 language. Heritage root should *weight the probability*, not *determine the outcome*. Ozzie is right — full determination kills the discovery moment. A Frost building with a rooftop bar is memorable precisely because it is unexpected. The correction stands. + +--- + +## Corrections — Three Items + +### Correction 1 (Critical): T5/T7 Terrain Palette Numbering Wrong + +D-READY-6 lists the 8 base terrain types as: T1 temperate farmland / T2 industrial farmland / T3 wilderness / T4 grassland / **T5 mountain** / T6 beach / **T7 wetland** / T8 desert. + +My Round 3 specification was: + +| ID | Name | +|----|------| +| T5 | Coastal water | +| T6 | Beach/coastal margin | +| T7 | Mountain/high terrain | +| T8 | Desert/arid | + +The outcomes document has T5 and T7 transposed, and **"wetland" is not in my original 8 types at all** — it replaced mountain. This is a factual error. + +Correct T5 = **Coastal water** (deep near-black blue, animated specular reflection, the terrain type referenced by D-READY-7's horizon view corridor guarantee). Correct T6 = **Beach/coastal margin** (warm dark tan). Correct T7 = **Mountain/high terrain** (dark blue-grey stone, snow at elevation). Wetland was never specified — if it needs to be added, it requires design work as a 9th type, not a silent replacement. + +**This error matters because:** T5 (Coastal water) is the terrain type that triggers the D-READY-7 horizon view corridor guarantee. If T5 is mountain, the coastal guarantee has no palette to reference. + +**Required fix in D-READY-6:** Correct the T5/T6/T7/T8 labels to match my Round 3 specification. Remove "wetland." If wetland terrain is needed for the game, file it as a new type with a new T-number. + +--- + +### Correction 2: D-READY-9 — Araminta's Authoring Domain Listed Incomplete + +D-READY-9 describes my authoring domain as: *"object sets, arrangement algorithms, lighting temperature (TOML modifier files, one per heritage root)".* + +My Round 4 TOML schema included additional sections not captured here. The full domain covers: + +- `[floor].variant_preference` — floor surface texture (worn_path, pressed_earth, etc.) — part of visual expression, not organizational grammar +- `[overhead].density_factor` — flora/canopy density, a continuous visual parameter +- `[overhead].character` — overhead object character (personal_organic, industrial_grid, etc.) +- `[structure].primary_material` / `material_tone_shift` — wall material character and color temperature shift +- `[boundaries].fence_type` — boundary/fence material (trellis_wood, stone_wall, wire_mesh, etc.) + +Wall material character and boundary material are part of my domain — not Miri's. An implementer reading D-READY-9 would assign structural material selection to Miri (organizational principles) when these visual expression fields belong with me. + +**Required fix in D-READY-9:** Update Araminta's domain to: *"object sets, arrangement algorithms, floor surface variants, overhead flora density and character, wall/structure material character, boundary material type, lighting temperature (TOML modifier files, one per heritage root)."* + +--- + +### Correction 3: D-READY-13 — Vessel Visual Grammar Not Referenced + +The MobileChunk section correctly captures structural fields, movement states, cultural grammar (via `TransitSocialModifier`), and departure schedules. It references Miri's canonical spec for cultural grammar. + +It does not reference the vessel visual grammar I specified in Round 4 — five rules that apply to all MobileChunk-type spaces: + +1. Exterior hull uses vessel-identity material (not zone palette) +2. Window tiles reveal exterior context (docked vs. in transit) +3. Compression modifier tightens proportions throughout +4. Section transitions use vessel-identity threshold elements +5. Class stratification expressed through proportion, not palette change + +An implementer reading D-READY-13 in isolation has no source for how vessels look different from buildings. The compression modifier and hull-identity threshold material are required for correct chunk fill. + +**Required fix:** Add to D-READY-13: *"Vessel visual grammar: see `docs/workshops/generator-architecture/araminta-round4.md` §2. Five rules govern visual distinction of MobileChunk interiors from static zone spaces."* + +--- + +### Correction 4: D-READY-5 — Destruction Stage Sequence Not Enumerated; Destruction Palette Absent + +D-READY-5 references "Stage 2 (Fresh Aftermath)" and "Stage 3" in the trauma event mapping but never enumerates the full stage sequence. The destruction palette constraint is also absent. + +**Required addition — stage sequence:** + +| Stage | Name | Visual state | +|-------|------|-------------| +| 1 | Active | Event in progress; DamageOverlay rendering live | +| 2 | Fresh Aftermath | Structure breached; scorch, rubble, debris tiles visible | +| 3 | Stabilized | Debris cleared; structural state permanent | +| 4 | Reconstruction | Scaffolding tiles, incomplete floor sections | +| 5 | Healed Scar | Functional again; residual visual tells remain | + +**Required addition — destruction palette constraint:** + +> Destruction palette is **corruption-only**: no new colors are introduced by destruction. Existing zone palette tiles are darkened, desaturated, or replaced with structural-damage variants drawn from the same palette family. Single exception: `#c8d8f0` (open-sky tile) appears at 100% intensity when a roofed structure has its roof removed — the only color that destruction introduces. Implementers must not create a separate destruction color set. + +This constraint is needed in the D-record to prevent implementers from adding freestanding destruction palette colors. Without it, different implementations will diverge on whether destruction has its own visual language or borrows from zone palettes. + +--- + +## Sign-Off (Updated) + +With the four corrections above applied, the document is accurate for the visual design domain. The 14 D-records are ready to file. + +*Araminta — Round 5 complete.* diff --git a/docs/workshops/generator-architecture/gestalt-round1.md b/docs/workshops/generator-architecture/gestalt-round1.md new file mode 100644 index 000000000..18cd478ac --- /dev/null +++ b/docs/workshops/generator-architecture/gestalt-round1.md @@ -0,0 +1,261 @@ +# Generator Architecture Workshop — Round 1: Gestalt + +**Role:** Systems Design / Gameplay Loop Requirements +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## Framing Statement + +The generator must produce spaces that create **information asymmetry** — not just visually plausible locations, but locations where what you know, who you know, and where you can go produce fundamentally different gameplay experiences. Every spatial guarantee below is ultimately a guarantee about asymmetric information production. + +Let me break down what the generator actually needs to produce, mechanically. + +--- + +## 1. What Gameplay Loops Does the Generator Need to Support? + +The game has five confirmed gameplay loops (D-007 five pillars). The generator must support all five with spatial affordances. + +### Loop 1: Observation Loop +**Mechanic:** Player moves through space, maintains Careful/Walk stances (D-053), observes NPC tells, detects anomalies, fires monologue recognition events. +**Generator requirement:** Every district needs spaces where NPCs are observable from cover — chokepoints, corridors, elevated positions, furniture arrangements that create natural sightline asymmetry. The functional cluster (D-025: 15-40 tile connected space with internal sightlines) is the atomic observable unit. The generator must instantiate these; it cannot produce districts where all spaces are either completely open or completely enclosed. + +### Loop 2: Investigation Loop +**Mechanic:** observe → notice → follow → discover. Three confirmed path types from D-093 (Path A: pattern/digital, Path B: physical/spatial, Path C: institutional/social). Each path yields unique evidence inaccessible to the other paths. +**Generator requirement:** Every generated district must support all three path types. This means the spatial hierarchy must guarantee: (A) a zone with Meridian/camera log access (Path A), (B) a physical traversal corridor with acoustic gaps or spatial quirks (Path B), and (C) a social venue where institutional relationships develop (Path C). These are spatial guarantees, not content guarantees — the generator places the slots; templates fill them. + +### Loop 3: Social Manipulation Loop +**Mechanic:** Player builds rapport, climbs trust tiers (D-075), unlocks dialogue options (D-062 — invisible until unlocked), triggers unprompted disclosures. Social sites (D-025: 4-8 NPCs within 15-40 tile cluster) are the primary stage. +**Generator requirement:** Every district needs at least one social venue (bar-type social site) that is publicly accessible without authority credentials, AND at least one workplace social site where insider access is required. These are not the same space. The generator must distinguish them at the zoning/amenity stage. + +### Loop 4: Stealth/Exposure Loop +**Mechanic:** Player manages their own information exposure — who saw them, where they went, what was recorded. Meridian dead spots (D-093: maintenance corridors, z=0 layer) are critical for both archetypes. +**Generator requirement:** Every district must have at least one zone with degraded Meridian coverage where private exchanges can occur. The smuggler needs it for ring operations. The detective needs it for confidential source contact. Without this guarantee, both archetypes lose a core tool. + +### Loop 5: Daily Life / Routine Loop (Substrate) +**Mechanic:** Player witnesses NPC routines, builds complacency, creates emotional attachment that makes contamination hit harder (D-023: "daily life is the substrate; conspiracies are weather"). D-031 day phases drive NPC routine transitions. +**Generator requirement:** The district must have transit nodes (encounter nodes where NPC routing creates observable patterns), temporal rhythm affordances (NPC populations change by day phase), and density variation (not all spaces always populated). The transit platform from D-093 is the canonical example — workers arrive bar-side at shift change, the pattern is observable. + +--- + +## 2. What Must the Generator Guarantee? + +These are non-negotiable spatial guarantees. If a generated district fails any of these, it cannot support the core gameplay loops. + +### Guarantee 1: Surveillance Chokepoint +**Definition:** A spatial bottleneck where NPC traffic must pass and is observable from a fixed position by a player in Walk or Careful stance. +**Why mandatory:** The observation loop (Loop 1) requires this. Without at least one chokepoint, the player has no reliable observation position. Evidence gathered here feeds Path A and Path B investigation. +**Example from D-093:** Gate concourse (40×8 sim tiles, public zone) + transit platform (bar-side encounter node, ~12×8 visual). These are chokepoints by geometry — narrow corridors NPCs must traverse. +**Generator mechanism:** Infrastructure stage must place at least one high-traffic corridor connecting the transit ingress to the district's social sites. Block generation must not route NPCs around it. + +### Guarantee 2: Quiet Zone (Meridian Dead Spot) +**Definition:** A zone with low or absent Meridian coverage and low ambient NPC traffic, suitable for private exchanges. +**Why mandatory:** The stealth/exposure loop (Loop 4) requires this for both archetypes. The ring (and its analog in any generated district) needs operational dead spots. Without this, the 30/50/20 entanglement model (D-029) has nowhere to put the entangled 20%. +**Example from D-093:** Maintenance corridors at z=0 (minimal Meridian, no general NPC traffic). Restricted storage corridor (acoustic gap, accessible only via specific physical path). +**Generator mechanism:** Infrastructure stage assigns Meridian coverage per zone. Zoning stage marks these zones. At least one zone per district must be flagged `meridian_coverage: degraded` or `minimal`. + +### Guarantee 3: Social Manipulation Hub (Public Social Site) +**Definition:** A social venue with D-025 cluster properties (4-8 NPCs, 15-40 tile connected space, internal sightlines) that is publicly accessible — no access credentials required. +**Why mandatory:** Social manipulation loop (Loop 3) requires a space where both archetypes can build relationships. Bar-type social sites are the canonical form. Without this, trust tier progression has no natural stage. +**Example from D-093:** The Last Shift / "Lera's" (bar). Public access, established regulars, D-025-compliant cluster. +**Generator mechanism:** Amenities stage must instantiate at least one bar/social venue type. Its chunk fill must satisfy D-025 sightline requirements — the quarter system cannot fragment this into isolated enclosed rooms. + +### Guarantee 4: Insider Access Zone (Workplace Social Site) +**Definition:** A functional cluster where employment/membership (not authority) grants access that the detective archetype cannot obtain through institutional credentials. +**Why mandatory:** Asymmetric access between archetypes (D-007 pillar 1) requires at least one zone where the smuggler's insider knowledge is an advantage. This is what makes two-character play produce fundamentally different games (D-027 criterion 2). +**Example from D-093:** The Terminal (logistics hub) — freight workers have insider access, detective requires Commission warrant to access operational areas. +**Generator mechanism:** Zoning stage marks `access_tier: insider` zones that require employment/social credentials, not authority credentials. + +### Guarantee 5: Institutional Authority Zone (Restricted Access) +**Definition:** A zone where authority credentials (detective archetype) provide access that the insider (smuggler archetype) cannot obtain through social rapport. +**Why mandatory:** Mirror of Guarantee 4. Both archetypes must have at least one access advantage the other lacks. This is the mechanical expression of "two keyholes on the same world" (D-027). +**Example from D-093:** Gate cluster restricted zones — Commission inspector access; gate aperture chamber (8×4 restricted); observation gallery (z=2, Commission-only). +**Generator mechanism:** Zoning stage marks `access_tier: restricted/authority` zones. At least one per district. + +### Guarantee 6: Triangle Social Geometry +**Definition:** The district's NPC population must form at least 2 active triangles (D-024 minimum: "2 per template minimum, 1 cross-template"). Triangles require NPCs with conflicting interests positioned in overlapping spatial zones. +**Why mandatory:** Triangles are "the atomic unit of social intrigue" (D-024). Without them, investigation and social manipulation loops have no payoff. The 30/50/20 model (D-029) requires triangles to exist across all tiers. +**Generator mechanism:** Population stage assigns NPCs with conflicting Want/Secret/Relationship axes. District skeleton output must include at minimum: 2 triangles whose members' daily routines bring them into shared spatial zones. Cross-template triangle (D-024 minimum) means members must span at least 2 social sites. + +### Guarantee 7: Three-Path Investigation Structure +**Definition:** Three distinct evidence chains, each requiring different spatial access or social credentials. +**Why mandatory:** Path A/B/C structure from D-093 is the mechanical proof-of-concept for investigation gameplay. A single investigation path collapses asymmetric information into a linear puzzle. +**Generator mechanism:** +- **Path A** (pattern/digital): requires a zone with Meridian/camera access point +- **Path B** (physical): requires a physical traversal route with at least one acoustic anomaly or spatial gap +- **Path C** (institutional/social): requires a social site where rapport-building yields unique evidence + +Each path must produce at least one piece of evidence not obtainable via the other paths. + +--- + +## 3. How Do the Pipeline Stages Map to Gameplay-Relevant Structures? + +Taking the Cities Skylines top-down pipeline as the working model: + +``` +Geography + → Infrastructure (transport nodes, utilities, Meridian coverage) + → Amenities & Services (social site types, access tiers) + → Population (NPC count, entanglement rate, triangle seeding) + → Zoning (access tier assignment per zone) + → Block generation (multi-block reservations, functional type per block) + → Chunk fill (social site template instantiation, sub-chunk quarter assignment) +``` + +### Geography → Spatial Affordance Type +**Gameplay product:** Determines what kinds of spaces are even possible. +- Station → constrained corridors, z-level variation, Meridian infrastructure baked in +- Planet-side → open space, variable Meridian coverage, weather effects (D-050: fog degrades vision cones equally) +- Orbital → low gravity implications, different spatial scale +The generator doesn't need to solve this in v0.1 (one station, one district). But the architecture must accept geography as an input to every downstream stage. + +### Infrastructure → Surveillance + Stealth Topology +**Gameplay product:** Where observation is rewarded; where privacy is possible. +Critical generator outputs at this stage: +- **Transit node placement** → chokepoint geometry (Guarantee 1) +- **Meridian coverage assignment per zone** → quiet zone placement (Guarantee 2) +- **Utility corridor routing** → maintenance spine, provides physical traversal path for Path B +- **Z-level assignment** → which spaces are at ground level vs. elevated vs. sub-level (affects LOS per D-066) + +**Key design requirement:** Infrastructure stage must not be purely functional — it must be evaluated through "does this produce interesting observation positions and interesting private zones?" + +### Amenities & Services → Social Site Type Assignment +**Gameplay product:** Which social loops are available and where. +Critical generator outputs: +- **Social venue (bar-type)** → public social site, social manipulation hub (Guarantee 3) +- **Workplace (terminal-type)** → insider access zone (Guarantee 4) +- **Institutional space** → authority access zone (Guarantee 5) +- **Medical/service** → secondary social contact points, NPC routing attractors + +This is where the D-025 "social site / functional cluster" concept is first instantiated as a type, not yet filled. The amenities stage selects template tags; the chunk fill stage instantiates the template. + +### Population → Triangle and Entanglement Seeding +**Gameplay product:** The human drama that investigation reveals. +Critical outputs: +- **NPC count per zone** → density sufficient for 30/50/20 split (D-029) +- **Entanglement rate** → seeded per-game, varies to prevent metagaming (D-029) +- **Triangle assignment** → minimum 2 active triangles (D-024), placed across confirmed social sites +- **NPC axis rolls** → Want/Secret/Relationship/Routine assigned; these must produce spatial conflicts (NPC A works at the terminal but secretly meets NPC B in the maintenance corridor) + +**Critical relationship with zoning:** Population and Zoning must be jointly optimized. An NPC with a secret that requires access to a restricted zone must be assigned a role that gives them that access. The generator cannot assign secrets that have no plausible staging ground. + +### Zoning → Access Tier Palette +**Gameplay product:** The invisible layer of gates that defines what each archetype can see. +Zone types required (from D-093 example): +- `public` → anyone can enter +- `semi-public` → soft social gate (regulars, workers) +- `semi-private` → employment/insider required +- `private` → insider + relationship required +- `restricted` → authority credentials required +- `commission-only` → detective-archetype exclusive +- `maintenance` → ring-insider exclusive or physical bypass required + +**Key design requirement:** Every district must have at minimum one zone from each end of the spectrum (public ↔ restricted/maintenance). Middle tiers provide the interesting gameplay — they're contestable. + +### Block Generation → Multi-Block Structure Reservation +**Gameplay product:** The "large civic structures" (gate terminals, stadiums, gov buildings) that anchor district identity and create mandatory routing patterns. +Critical requirements: +- Reserve multi-block footprint before chunk fill runs +- Multi-block structures create natural chokepoints at their approaches (Guarantee 1) +- They contain the institutional access zones (Guarantee 5) +- Their internal layout is constrained but not dictated — a gate terminal must have a gate concourse (public) AND a restricted zone; exact dimensions are filled at chunk level + +### Chunk Fill → Social Site Template Instantiation +**Gameplay product:** The specific spaces players actually inhabit. +This is where D-025 templates are instantiated. The chunk fill stage: +1. Selects a social site template tag (assigned at amenities stage) +2. Configures sub-chunk quarters for that template's spatial requirements +3. Places NPC slots, interaction points, overhearing positions, evidence anchors +4. Ensures D-025 sightline requirement within the cluster's tile footprint + +--- + +## 4. What Constraints Does the Triangle Template System (D-025) Place on Chunk Fill? + +This is the critical mechanics question. Let me map it precisely. + +### Constraint 1: Connected Space Continuity (15-40 sim tile radius) +**D-025 says:** The functional cluster is "15-40 tiles of connected space with internal sightlines." +**Constraint on chunk fill:** The sub-chunk quarter system must not produce fully enclosed, sightline-isolated quarters within a single social site's footprint. A 64×64 sim tile chunk (32m) can fit a 40-tile cluster comfortably — BUT the quarter merge/split rules must preserve internal connectivity. +**Practical rule:** If a social site spans N quarters, all N quarters must share at least one sightline corridor. A 2×2 quarter full merge (open floor) trivially satisfies this. An L-shaped 3-quarter configuration must not have the interior angle be a solid wall. + +### Constraint 2: Internal Sightline Preservation +**D-025 says:** "physical cluster defines spatial identity (sightlines, overhearing, public/private)." +**Constraint on chunk fill:** Quarter configurations that fragment a social site into acoustically isolated boxes violate the overhearing mechanic (D-018 sound model). The observation loop (Loop 1) depends on players being able to hear conversations from adjacent positions. +**Practical rule:** Social site chunks must have at least one "soft partition" zone — a position where the player can hear adjacent conversations but NPCs have reasonable privacy expectation. This is the eavesdrop sweet spot that rewards Careful stance. + +### Constraint 3: NPC Single-Ownership with Cross-Reference Links +**D-025 says:** "NPCs are owned by exactly one template with reference links to others." +**Constraint on chunk fill:** When a district has multiple social sites, the chunk fill for each site must assign NPC ownership unambiguously. An NPC cannot "belong" to two filled chunks. +**Cross-template contamination** (D-025: "One NPC can hold roles in multiple social sites") is expressed through reference links, not through shared ownership. The chunk fill must represent this as: NPC primary slot in Chunk A, secondary reference appearance in Chunk B (e.g., a bar regular who also works at the terminal). +**Practical implication for the generator:** The population stage (upstream) must assign NPC primary templates BEFORE chunk fill runs. Chunk fill uses the population stage output, not the other way around. + +### Constraint 4: The 4-8 NPC Density Window +**D-025 says:** "4-8 NPCs who regularly interact." +**Constraint on chunk fill:** A social site's chunk cannot be over-filled (>8 regularly interacting NPCs in one cluster) or under-filled (<4 interacting NPCs). Tier 3 background NPCs (D-029: 30% flat wallpaper) pass through but don't "belong" to the cluster. +**Quarter implication:** A full 2×2 quarter merge producing a large open floor can host 4-8 NPCs in a social site. A single-quarter configuration (¼ chunk = 32×32 sim tiles = 16m) can host a smaller social site, but may be too small for 8 NPCs with meaningful daily routines. The generator should default: smaller clusters in single quarters (4-5 NPCs), larger clusters in merged quarter configurations (6-8 NPCs). + +### Constraint 5: Public/Private Gradient Within the Cluster +**D-025 says:** "Physical cluster defines spatial identity (sightlines, overhearing, public/private)." +**Constraint on chunk fill:** Within a single functional cluster, there must be spatial differentiation between public-facing areas and private areas. The bar example from D-093: bar area (public, all access) + back room (private, insider only) + below-bar maintenance access (restricted, ring members only). This tri-zone structure within one cluster must be representable in the quarter system. +**Quarter solution:** A 4-quarter chunk representing a bar might configure as: +- 2 quarters merged: main bar floor (public) +- 1 quarter: back area / staff zone (semi-private) +- 1 quarter gap or sub-quarter shack: storage/access point (private/restricted) + +### Constraint 6: The "Invisible Infrastructure" Principle (G-08 from D-093) +**D-093 says:** "every ring location reads as mundane; criminal function visible only to those who know." +**Constraint on chunk fill:** This is a design constraint on HOW templates are instantiated. The chunk fill cannot place obvious "smuggling room" tiles. The physical arrangement must read as functional for mundane purposes while being usable for criminal ones. +**Practical implication:** Templates must have a "surface reading" (manifest function) and a "second reading" (criminal function). Chunk fill must not diverge these visually — the same tile arrangement serves both. The distinction comes from NPC knowledge (D-041 knowledge graph), not from tile art. + +--- + +## 5. Open Questions I'm Flagging for Round 2 + +### Q-A: Does Population Precede or Follow Zoning in Our Pipeline? +The Cities Skylines model puts population after zoning. But our game has a critical dependency: **NPC secrets must have plausible staging grounds in the spatial layout**. An NPC with a secret meeting in a restricted zone requires that restricted zone to exist before the NPC can be validly generated. + +**My position:** Population follows zoning for assignment (zones exist first), but population requirements should CONSTRAIN zoning (a zone must exist that satisfies the minimum secret/access requirements of the triangle configuration). This creates a feedback loop between population and zoning that the pipeline must resolve. Tyre will need to address whether this creates implementation complexity. + +### Q-B: Triangle Template (D-025) Instantiation Stage +The workshop brief asks where triangle templates are instantiated in the pipeline. **My position:** Triangle configuration is determined at the population stage (which NPCs exist, which have conflicting interests). Social site templates (D-025) are instantiated at chunk fill. The population stage produces a "social graph" that the chunk fill stage then places into physical space. + +This means: social graph → chunk fill. NOT: chunk fill → social graph. The generator must not produce spatial arrangements and then try to fill them with compatible social graphs. The social graph drives spatial requirements. + +### Q-C: What Is the Minimum Generated District? +For v0.1 validation purposes: if we express Sova Transit District as generator output, what is the minimum generator that can reproduce it? I'd propose: +- 1 workplace social site (terminal-type) +- 1 social venue (bar-type) +- 1 maintenance spine (quiet zone) +- 1 transit encounter node +- 1 restricted zone (authority access) +- 2 active triangles across the above sites + +This is the "minimum viable district" that satisfies all seven guarantees. Anything below this fails at least one gameplay loop. + +--- + +## Summary: Generator Requirements as a Checklist + +| Requirement | Source | Stage Where Guaranteed | +|---|---|---| +| Surveillance chokepoint | D-093, Loop 1 | Infrastructure | +| Meridian dead spot / quiet zone | D-093, D-029, Loop 4 | Infrastructure | +| Public social venue (bar-type, D-025 compliant) | D-025, Loop 3 | Amenities | +| Insider access zone (workplace-type) | D-007, Loop 3 | Zoning | +| Authority access zone (restricted-type) | D-007, Loop 3 | Zoning | +| Triangle social geometry (≥2 active, D-024) | D-024, D-029, Loop 2 | Population | +| Three-path investigation structure (A/B/C) | D-093, Loop 2 | Multi-stage | +| Transit encounter node | D-093, D-095, Loop 5 | Infrastructure | +| Internal sightlines within social sites | D-025, Loop 1 | Chunk fill | +| Public/private gradient within clusters | D-025, Loops 1-3 | Chunk fill | +| Invisible infrastructure (dual-reading spaces) | D-093 G-08, Loop 4 | Chunk fill | + +Every pipeline stage maps to at least one row. No guarantee is produced "for free" — each requires an explicit generator mechanism. + +--- + +*Gestalt — Round 1 complete. Ready to respond to Tyre (technical constraints), Miri (worldbuilding inputs), and Araminta (visual coherence) in Round 2.* diff --git a/docs/workshops/generator-architecture/gestalt-round2.md b/docs/workshops/generator-architecture/gestalt-round2.md new file mode 100644 index 000000000..528a3034d --- /dev/null +++ b/docs/workshops/generator-architecture/gestalt-round2.md @@ -0,0 +1,438 @@ +# Generator Architecture Workshop — Round 2: Gestalt + +**Role:** Systems Design / Gameplay Loop Requirements +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## Framing: Acknowledging the Lead Directive + +The lead directive lands correctly. My Round 1 analysis was framed entirely through the investigation lens. That was useful for establishing what the generator must never fail to produce — but it was insufficient for a game that promises to support tycoon play, dating sim / social relationship play, and political drama as equally valid first-class experiences. + +Let me break down what this means mechanically, then propose a pipeline that serves all of them. + +The central insight: **the spatial guarantees from Round 1 are not investigation-specific. They're human-social-activity-specific.** A surveillance chokepoint is also a trade route bottleneck, a political rally point, and a serendipitous encounter location. An informal zone is also a grey market space, a tryst location, and a dissident meeting point. The 7 guarantees from Round 1 were named wrong — they were investigation vocabulary for playstyle-agnostic spatial archetypes. + +This round I'm going to rename them, show how they serve every playstyle, add the guarantees that investigation didn't require, and propose a concrete two-phase pipeline that Tyre, Miri, and Ozzie's inputs have informed. + +--- + +## 1. The Revised Spatial Guarantee Set: Playstyle-Agnostic Archetypes + +Drop the investigation vocabulary. Here are the 7 spatial archetypes every generated district must contain, with their multi-playstyle readings: + +### Archetype 1: Traffic Chokepoint +**Physical definition:** A spatial bottleneck where significant NPC traffic must pass and is observable from a fixed adjacent position. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Surveillance position — observe tells, track movements, spot anomalies | +| Tycoon | Trade route leverage — who controls the choke controls the flow of goods | +| Dating / Social | Serendipitous encounter point — this is where you "happen to run into" someone | +| Political | Campaign territory — public visibility, speech platform, constituency pressure | + +### Archetype 2: Informal Zone +**Physical definition:** A zone with degraded institutional coverage, low ambient traffic, suitable for private or unofficial activity. Includes maintenance corridors, service back-alleys, rooftop access, below-street passages. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Quiet zone — ring operations, confidential source meetings, dead drops | +| Tycoon | Grey market space — off-ledger deals, unofficial distribution, tax-adjacent commerce | +| Dating / Social | Privacy space — the rendezvous location, the conversation that can't happen in public | +| Political | Back-channel space — the meeting that isn't on the record | + +**Note for the generator:** This archetype must be generated deliberately, not incidentally. It is the space that official documentation doesn't account for (Miri's C-5 insight). Every district must have at least one, and it must be findable without being guided to. + +### Archetype 3: Social Hub +**Physical definition:** A D-025-compliant functional cluster (4-8 NPCs, 15-40 tile connected space, internal sightlines) that is publicly accessible without institutional credentials. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Rapport-building stage — trust tier progression, gossip extraction | +| Tycoon | Networking hub — find suppliers, hear rumors of demand, recruit partners | +| Dating / Social | Romance venue — relationship initiation, shared leisure, social graph expansion | +| Political | Influence gathering — read public mood, identify allies, make your presence felt | + +### Archetype 4: Institutional Space +**Physical definition:** A zone where official credentials, position, or authority determine access — and where institutional actors can be leveraged, pressured, or circumvented. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Authority access zone — show credentials, pull records, compel cooperation | +| Tycoon | Licensing / permit space — register trade routes, dispute cargo claims, bribe inspectors | +| Dating / Social | Official encounter context — the formal interaction that begins some relationships | +| Political | Power center — submit proposals, apply pressure, climb the institutional hierarchy | + +### Archetype 5: Insider Space +**Physical definition:** A zone where community membership, employment, or social standing (not official credentials) determines access. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Insider access zone — ring operations visible only to those who belong | +| Tycoon | Guild / union / cooperative — preferred trade terms, insider pricing, loyal suppliers | +| Dating / Social | Close network space — the bar where the friend group drinks, the community event | +| Political | Party / faction HQ — where the organized power lives | + +### Archetype 6: Economic Node +**Physical definition:** A space where goods, services, or information with economic value change hands. Includes markets, logistics points, trade counters, informal exchanges. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Evidence trail — cargo manifests, transaction logs, the money that follows the crime | +| Tycoon | Primary profit opportunity — buy low, sell high, establish routes | +| Dating / Social | Shared activity — shopping together, market browsing, the informal economic life | +| Political | Leverage point — economic actors are political actors; control trade = control votes | + +**This is the archetype my Round 1 analysis missed.** It's present in v0.1 (the Terminal is an economic node) but I didn't name it as a required generator guarantee. It's non-negotiable for tycoon play. + +### Archetype 7: Encounter Corridor +**Physical definition:** A space primarily designed for movement — transit corridors, promenades, public routes — where encounters happen but nobody lingers. +**By playstyle:** +| Playstyle | What it does for you | +|---|---| +| Investigation | Observation route — follow NPCs, track patterns, sense when routines break | +| Tycoon | Supply chain link — goods move through here; disrupting/controlling it is leverage | +| Dating / Social | Casual crossing point — daily routine overlaps, the route you "happen to share" | +| Political | Visibility territory — seen being there communicates political alignment | + +**What I'm dropping from Round 1:** "Three-path investigation structure (A/B/C)" as a standalone guarantee. In the multi-playstyle framework, this becomes: **at least 3 distinct engagement vectors must exist for each playstyle**. For investigation these are pattern/physical/social. For tycoon they're supply/demand/regulation. For social/dating they're work-meeting/social-meeting/crisis-meeting. The generator doesn't need to know which playstyle the player chose — it needs to produce enough structural variety that at least 3 paths exist for any approach. + +--- + +## 2. New Playstyle-Specific Guarantees + +The 7 archetypes above are the floor. These additional guarantees serve playstyles the 7 archetypes don't fully address: + +### Tycoon-Specific: Economic Asymmetry Signal +The district must contain at least one indicator of **economic imbalance** — something being undersupplied, oversupplied, restricted, or priced unequally across zones. This is the tycoon's opening opportunity. In v0.1 terms: aftermarket lattice components are undersupplied because Commission regulation creates artificial scarcity. The generator must produce an analogous economic tension in every district. +**Implementation:** The society profile's `economic_pressure` field (Miri's ingredient D) directly determines what the asymmetry is. `tight-margin` + `prohibition-economy` produces the Sova scenario. Different pressure combinations produce different economic tensions. The generator surfaces these as locatable economic nodes with discoverable demand gaps. + +### Social/Dating-Specific: Temporal Encounter Window +The district must produce at least one NPC whose daily routine creates **predictable, repeatable encounter opportunities** outside of work context. This is the "regulars at the bar at 1800 every day" pattern. The tycoon has stable trade windows; the romance player needs the equivalent. +**Implementation:** This is a property of NPC routine generation (Stage 5), but the generator must guarantee the SPATIAL CONDITION: a social hub with defined temporal peaks (morning rush, evening gathering, night shift crowd). The D-031 day-phase system already provides this — social hubs must be tagged with active phases during skeleton generation. + +### Political-Specific: Power Gradient Visibility +The district must make its power topology **spatially legible** — who has authority over whom, and where that authority is exercised, must be readable from the space without metagame knowledge. +**Implementation:** Araminta's zone palette and lighting temperature gradient already does this for institutional vs. social zones. The generator must additionally guarantee that at least one NPC occupies a visible authority position (SYSTEM pattern per Q-033) whose institutional relationship to other social sites is observable. The detective already gets this through the Commission; the political player needs the equivalent civic power holder. + +### Non-Urban Specific: Natural Chokepoint +**For non-urban templates (farmland, wilderness, maritime, ski resort, beach):** The Traffic Chokepoint archetype takes a different form — a mountain pass, a harbor entrance, a river ford, a seasonal trail. These are geographically determined rather than architecturally determined. +**Generator requirement:** When `SettingGeometry = Rural / Maritime / Wilderness`, the infrastructure stage replaces architectural corridor planning with **terrain chokepoint identification**. The spatial archetype is the same; the generator mechanism is different. + +--- + +## 3. Two-Phase Pipeline Proposal + +The lead directive specifies: world/district generation is a background prep pass (spare CPU core). Local area generation is the fine-tuned interactive system. These are architecturally separate. + +Here is the concrete two-phase pipeline, integrating Tyre's data structures, Miri's worldbuilding inputs, and the multi-playstyle requirements: + +--- + +### PHASE 1 — Background Prep (Asynchronous, no player interaction required) + +These stages can run while the player is already in a different location. Output is cached; player never waits. + +#### Stage 0: World Significance Tier +**Input:** Master world seed, galaxy topology +**Output:** Per-location `SignificanceTier` + `SettingGeometry` + +| Tier | What gets generated | Social density | Active scenarios | +|---|---|---|---| +| Center-stage | Full district generation, all phases | High | Multiple | +| Regional | 1-4 districts, full phases | Medium | 1-2 | +| Backwater | 1 district or partial, condensed | Low | 0-1 | +| Waypoint | Tier 3 only — spatial skeleton, no NPCs | Minimal | None | +| Insignificant | Not generated until player approaches | — | — | + +This is not a Phase 1 computation — it's the classification that governs how much Phase 1 runs. A waypoint location gets a skeleton only. An insignificant place doesn't exist until the player gets close. + +`SettingGeometry` enum (the lead directive requires this be first-class): +``` +Station → zone-and-level grid +Urban → terrain-influenced spread +Rural → low-density farmland/town spread +Maritime → coastal topology with water interface +Wilderness → minimal infrastructure, scattered POIs +Specialized → resort, research, military (single economic function) +``` + +#### Stage 1: Society Profile Assembly +**Input:** World seed, system political classification, heritage ingredients (Q-032) +**Output:** `SocietyProfile` struct + +This is Miri's full ingredients menu: Heritage Roots (blend weights), Settlement Motivation, Economic Function, Economic Pressure (1-2), Drift Stage, Absence Parameters, Faction Presence tiers, tech-level 3-axis profile. + +Key outputs that feed downstream stages: +- `social.privacy_level` → access tier thresholds +- `trust.building_rate` → NPC trust progression speed +- `economic_pressure` → economic asymmetry type +- `meridian_coverage_baseline` → from tech-level axis 1 +- `active_phases` → which day phases have which social activity (D-031 integration) +- `cultural_tags` → for NPC name generation, ambient text flavor + +**On the serde question (Miri's OQ-5):** The society profile YAML must map to a Rust struct via serde. The nested structure (heritage with blend weights, faction presence with per-faction tiers) is manageable — Rust's serde_yaml handles this. The critical requirement is that blend weights sum to 1.0 and absence parameters serialize as `Option<T>` (NULL = None). Tyre should confirm the specific schema contract. + +#### Stage 2: District Skeleton Generation +**Input:** Society Profile, SignificanceTier, SettingGeometry, political classification +**Output:** `DistrictSkeleton` (Tyre's data structure, extended with `significance_tier` and `setting_geometry` fields) + +This stage is where the multi-playstyle spatial archetypes are guaranteed. The skeleton generator runs a **spatial guarantee audit** after producing a candidate skeleton: + +``` +For each of the 7 spatial archetypes: + Does the candidate skeleton contain at least 1 spatial site of this type? + If NO → regenerate or augment the skeleton until the guarantee is satisfied + +Additional per-playstyle checks: + Economic node present? (required for significance_tier ≥ backwater) + Temporal encounter window in social hub? (active_phases set?) + Power gradient visible? (SYSTEM-pattern NPC slot in institutional space?) + Informal zone explicitly flagged? (not inferred from access tier) +``` + +This audit is the enforcement mechanism. Without it, the generator can technically satisfy the schema while producing an unplayable district. + +**On edge bleed (lead directive):** The skeleton's `access_points` and `corridors` must extend to district edges as open connection slots. Neighboring districts resolve these connections when their own skeletons are generated. Blocks at district edges can be tagged `cross_district: true` on their social sites — these sites serve the district but are spatially adjacent to the neighbor. This is what creates the bleeding effect: a bar on the edge of the Transit District and the Residential Core serves both populations. It appears in both skeletons as a shared reference. + +**Historical event modifier pass:** After the base skeleton is generated, Miri's historical events apply as modifications: +1. Founding crisis → push drift_stage for affected zones, add blocked/repurposed blocks +2. Economic disruption → modify economic_pressure, introduce `abandoned_state` blocks +3. Institutional incursion → modify faction_presence_tier, add/remove authority zones + +These modifications produce Ozzie's "historical palimpsest" — the skeleton records not just the current state but the modifications that produced it. When the L-shaped building in ChunkLayout is `LShape { corner: NE, cause: emergency_extension }`, the cause field is available for environmental storytelling at chunk fill time. + +#### Stage 3: Block Planning +**Input:** DistrictSkeleton, economic tier, era tags (from era stratification) +**Output:** 16 `BlockSkeleton`s with ChunkLayouts, edge contracts, era tags, landmark slots + +Era tags are assigned at block level here (confirming Araminta's rule). The block planning stage also: +- Reserves multi-block footprints (gate terminals, parks, government buildings) +- Assigns landmark slots (1 per district quadrant, per Araminta's §5.3) +- Produces edge contracts for each chunk face (Tyre's Option A recommendation) +- Assigns density parameters (filled quarters per block by economic tier, per Araminta's §3.3) + +--- + +### PHASE 2 — Local Area Generation (On-Demand, Player-Proximate) + +These stages run as the player enters loading radius. They produce the tile-level and NPC-level content. + +#### Stage 4: Chunk Fill +**Input:** BlockSkeleton, edge contracts, D-025 template library, society profile, era tags +**Output:** Tile data for each 64×64 sim tile chunk + +This is where Tyre's 500ms budget applies. Template stamping is the mechanism — select a template from the library appropriate to the zone/era/access-tier, stamp it into the quarter configuration, then decorate procedurally within the template's variation space. + +Araminta's visual constraints apply here in full (zone palette, lighting temperature, saturation hierarchy, LOS anchor intervals, facade variation budget). + +**Quarter fill for unclaimed space:** Nigel's flavor structure categories + Araminta's empty quarter types need unification. I'm proposing they're the same system with two vocabularies: + +| Araminta type | Nigel category | Generator selection driver | +|---|---|---| +| Open plaza | Settlement indicator (seating clusters) | Social/community cultural heritage | +| Service alley | — (implicit) | Always present at ratio, not selected | +| Courtyard / garden | Settlement indicator (container gardens) | Heritage roots with outdoor-culture tradition | +| Vehicle/cargo staging | — (logistics function) | Economic function = logistics/manufacturing | +| Structural gap (undeveloped) | Economic stress (abandoned equipment) | Economic pressure = survival-gap or economic disruption event | +| — | Informal economy (market stalls) | Economic pressure = tight-margin or prohibition-economy | +| — | Faction presence (Commission kiosk) | Faction presence tier = standard or comprehensive | + +**The critical addition:** Flavor structure type does feed NPC generation. A `market_stall` quarter increases the probability of OPERATOR-pattern NPCs in the adjacent social site's population. A `commission_kiosk` quarter increases SYSTEM-pattern NPCs. A `settlement_indicator (shrine)` quarter increases ANCHOR-pattern NPCs. This answers Ozzie's OQ-3 — yes, what fills a quarter has downstream social consequences. + +#### Stage 5: NPC Instantiation +**Input:** Chunk fill (role slots), triangle assignments from DistrictSkeleton, society profile, world seed +**Output:** Generated NPCs with D-024 10-axis configurations, assigned roles, triangle connections, D-029 entanglement assignments + +NPC instantiation runs after chunk fill because the role slots and their spatial context must exist before NPCs can be validly assigned to them. The role slot (e.g., "this is a HANDLER-pattern position in the logistics hub social site") constrains which axis combinations are generated. + +This resolves my Round 1 OQ-A: **I was wrong about needing full co-resolution of population and zoning.** The feedback loop I identified is already handled by the skeleton stage: +- **Skeleton stage:** triangle TOPOLOGY is locked (role types, conflict structure, staging ground requirements) — this ensures staging grounds exist +- **Chunk fill stage:** role SLOTS are placed in the correct spatial positions +- **NPC instantiation:** axis VALUES are generated to fill role slots + +No feedback loop required. The skeleton guarantees that "a secret-holder NPC needs access to a restricted zone" by including a restricted zone in the skeleton's social site configuration for that template. The NPC instantiation then generates an NPC whose Secret axis is appropriate for that role. + +**REVISED position (OQ-A):** Zoning → Skeleton (triangle topology + role types) → Block Planning → Chunk Fill (role slots in space) → NPC Instantiation (NPC values). Sequential, no feedback loop. The skeleton stage's role-type specification is the mechanism that guaranteed staging grounds without requiring simultaneous resolution. + +#### Stage 6: Scenario Instantiation +**Input:** NPC population, Tier 1 module pool draw (D-023), world seed +**Output:** Active scenario config, entanglement assignments, evidence placement, active triangle configurations + +This is the final step that makes the district "live" as an investigation/tycoon/social/political space. The entanglement pattern (D-029) is seeded here — which 20% (variable per seed) of NPCs are entangled. Evidence placement is seeded here. Active triangles are activated here. + +For **tycoon play:** the economic asymmetry signals (economic node contents, demand gaps, price differentials) are set in this stage. The "who controls what" question in economic space is answered here. + +For **political play:** the power topology (which institutional NPC reports to which, what's up for contest, where alliances can shift) is configured in this stage. + +--- + +## 4. On Seed Architecture (OQ-2 from Round 1 Notes) + +The lead says seeds are solved: single master seed, don't over-engineer. Endorsing this position. + +**Single master seed derives all sub-seeds deterministically.** Same seed + different character = same world with different lenses. This is the correct design — it makes the "two playthroughs of the same seed" comparison a first-class experience (Nigel's comparison test, my D-027 criterion 2 goal). The player chose to play the same world from a different angle. They should discover exactly that. + +For the pipeline: each stage receives `derive_seed(master_seed, stage_id, location_id)` → deterministic sub-seed. No per-stage seed parameters. The seed architecture is solved at the hash function level. + +--- + +## 5. Non-Urban Templates and Insignificant Places + +The lead directive requires the architecture to handle: farmland, wilderness, secluded towns, ocean, boats, ski resorts, beaches. + +**These don't break the pipeline — they parameterize it differently.** + +The key parameters that change for non-urban settings: + +| Parameter | Urban (station) | Rural / Wilderness | Maritime | +|---|---|---|---| +| Block density | High (12-16 filled quarters) | Low (2-6 filled quarters) | Variable (coastal vs. open water) | +| Social hub type | Bar, restaurant, forum | Rural tavern, community hall, farm cooperative | Harbor tavern, ship crew quarters, fish market | +| Traffic chokepoint | Architectural corridor | Geographical feature (pass, ford, gate) | Harbor mouth, dock access, tide-dependent route | +| Meridian coverage | Standard to comprehensive | Sparse to absent | Absent on water, sparse on shore | +| NPC routine pattern | Shift-based (industrial) | Seasonal / agricultural | Tide-based / weather-dependent | +| Economic node | Logistics terminal, market | Farm output, resource extraction | Harbor trade, catch market | +| Informal zone | Maintenance corridor | Forest edge, ravine, cave | Below-deck, hidden cove, underwater | + +**The generator handles all of these through `SettingGeometry` as a first-class input.** The pipeline stages run the same logic; the input parameters shape what's generated at each stage. + +For **specialized settings** (ski resort, beach): +- These are `SettingGeometry::Specialized` with a single primary economic function (recreation/tourism) +- Social hubs are themed (lodge, beach bar, equipment rental as informal economy) +- The institutional space is smaller (ski patrol office, lifeguard station) but must exist +- The informal zone is the spaces off the maintained routes (off-piste terrain, after-hours areas) + +**Insignificant places** are handled by the significance tier system (Stage 0). A waypoint gets a Tier 3 spatial skeleton only — traversable space with environmental storytelling baked in, but no NPC simulation. These are the spaces that "exist because the geometry requires them to," not because they host drama. They're the breathing room that makes the center-stage locations feel like islands in a wider world. + +--- + +## 6. Responding to Tyre's Technical Constraints + +**On hierarchy depth:** Accepted. Quarters are generation-time layout constraints, not hierarchy levels. Four levels (Region/District/Block/Chunk) is correct. + +**On edge contracts:** Option A (edge contracts) is correct and essential for the district-edge bleed the lead requires. The `CorridorSpine` in the DistrictSkeleton should explicitly model cross-district continuity — a corridor that exits the district boundary records its target access point in the neighboring district. + +**On the district skeleton data structure:** Tyre's `DistrictSkeleton` needs three additions for multi-playstyle support: +1. `significance_tier: SignificanceTier` — governs how much of Phase 2 runs +2. `setting_geometry: SettingGeometry` — governs what spatial archetypes are appropriate +3. `guarantee_audit: GuaranteeAuditResult` — records which of the 7 archetypes are satisfied and where (makes the audit inspectable for validation) + +And a modification: `SocialSitePlacement.triangles` should carry not just `TriangleTemplate` (which NPC types conflict) but `triangle_purpose: TrianglePurpose` — an enum that records what the conflict is FOR (investigation/economic/political/social), allowing the scenario instantiation stage to activate relevant triangles based on active playstyle context. + +**On chunk fill budget:** Template stamping approach is correct for v0.2. One addition: the flavor structure assignment (Stage 4) should be pre-computed at block planning time (Stage 3) and stored in the BlockSkeleton, so chunk fill can read flavor_structure_type from its BlockSkeleton rather than computing it. This reduces per-chunk computation. + +**On the Transit District as ground truth:** Fully endorsed. The validation deliverable is: hand-authored `DistrictSkeleton` + 64 `ChunkData`s for the Transit District, validated against the guarantee audit (all 7 archetypes satisfied, all 3 multi-playstyle engagement vectors verified for investigation, tycoon, and social play simultaneously). If it satisfies all three lenses, the schema is correct. + +--- + +## 7. Responding to Miri's Worldbuilding Inputs + +**On society profile as first-class data structure:** Confirmed and extended. The society profile feeds Stage 1 (pre-pipeline) and is passed through the entire Phase 1 pipeline. Every downstream stage receives the society profile as a parameter, not just the abstract outputs from it. + +**On cultural variation producing mechanical variation:** Fully endorsed. The mechanism I'm adding: `social.privacy_level` from Miri's society profile maps directly to `access_tier.*` thresholds in the DistrictSkeleton's social sites. A high-privacy culture (Frost-dominant) produces more semi-private zones and slower trust-building windows at the same social site type. Same "bar" template, different mechanical behavior. + +**On era stratification:** Era tags live at block level (Stage 3), as Araminta confirmed. I'm adding that era tags also record the REASON for era stratification — specifically, whether a block's era differs from the district norm due to a historical event modifier. `era: Era2, era_cause: corporate_merger` tells the chunk fill stage to use era-2 materials AND to place visible seams/contrasts that suggest the building was constructed in two phases. + +**On grey economy as negative space:** The Stage 2 guarantee audit explicitly checks for `informal_zone: present`. This zone is not generated from amenities or zoning — it's generated from the infrastructure stage as an absence in the official map. The generator must model what's missing, not just what's placed. + +--- + +## 8. Responding to Ozzie's Fan Concerns + +**On Second Station Syndrome:** The multi-playstyle expansion actually solves this more thoroughly than investigation-only would. If the investigation-only player has learned "bar is always northwest," the tycoon-focused player might have shaped the district's economic node distribution differently. But more importantly: the social topology (who's in conflict, who's in power, what's the grey economy structure) varies by seed more than the spatial topology. The skeleton is never "the same shape wearing a different hat" — the shape is determined by the social topology, not the other way around. + +**On quarters feeling caused, not random:** The `LShapeCause` enum I proposed in Stage 2 is the mechanism. The generator doesn't produce L-shaped buildings randomly — it produces them because `emergency_extension` (an addition was forced by a blocked demolition), `acquisition_boundary` (two buildings merged under new ownership), or `organic_growth` (gradual expansion over time). At chunk fill time, these causes manifest as visual evidence: a material seam in `organic_growth`, a slightly different era tag on the added portion in `acquisition_boundary`. The player may not consciously read the cause, but they feel "this shape makes sense here." + +**On quarters producing social variation:** Confirmed, with the flavor-structure-to-NPC-affinity link I described in §3's chunk fill stage. Market stall quarter → OPERATOR-pattern NPC affinity. This makes the choice of flavor structure have downstream social consequences, not just visual ones. + +**On historical palimpsest:** Miri's era stratification + historical event modifier pass + my `era_cause` addition together produce this. The generator records history as a sequence of modifications, not just a current state. The chunk fill stage can read the full modification history and express it in the tiles. + +**On density contrast (the rhythm: crowded market → narrow corridor → sudden atrium):** This is produced by the combination of Araminta's density parameter (§3.3) and the landmark slot reservation (§5.3). The generator ensures density contrast exists within a district — a landmark multi-block structure is always adjacent to smaller-scale blocks, creating the density inversion Ozzie wants. This must be a *guarantee*, not a coincidence. + +**Generator rule I'm adding:** Adjacent blocks in a district must not all have the same density tier. The block planning stage must enforce density alternation — if block (2,2) is high-density, at least one of its 4 orthogonal neighbors must be medium or low density. + +--- + +## 9. Minimum Viable District — Multi-Playstyle Version + +Round 1's MVD was investigation-scoped. Here's the universal MVD that serves all four playstyles simultaneously: + +| Archetype | Physical form | Inv | Tycoon | Social | Politics | +|---|---|---|---|---|---| +| Traffic Chokepoint | Transit node or architectural corridor | ✓ | ✓ | ✓ | ✓ | +| Informal Zone | Maintenance spine or off-route passage | ✓ | ✓ | ✓ | ✓ | +| Social Hub | Bar or community gathering space | ✓ | ✓ | ✓ | ✓ | +| Institutional Space | Authority access zone | ✓ | ✓ | — | ✓ | +| Insider Space | Workplace or community insider zone | ✓ | ✓ | ✓ | ✓ | +| Economic Node | Market or logistics point | — | ✓ | — | ✓ | +| Encounter Corridor | Promenade or transit route | ✓ | — | ✓ | ✓ | +| Plus: ≥2 active social triangles | Seeded social conflict | ✓ | ✓ | ✓ | ✓ | +| Plus: ≥1 temporal encounter window | Day-phase social peak | — | — | ✓ | — | +| Plus: ≥1 power gradient signal | Visible authority NPC | ✓ | — | — | ✓ | +| Plus: ≥1 economic asymmetry signal | Demand/price gap | — | ✓ | — | ✓ | +| Plus: Density contrast (adjacent blocks ≠ same tier) | Visual rhythm | ✓ | ✓ | ✓ | ✓ | + +**Count:** 7 archetypes + 4 additional guarantees = 11 total checks in the guarantee audit. If a generated district passes all 11, it is playable for all four primary playstyles. + +--- + +## 10. Summary: Pipeline Stages with Multi-Playstyle Outputs + +``` +[Pre-pipeline] + World Significance Tier + Setting Geometry → governs how much generation runs + Society Profile (Miri's 6-category ingredients) → feeds everything downstream + +[PHASE 1 — Background Prep] + Stage 1: Society Profile Assembly + Outputs: privacy_level, trust.building_rate, economic_pressure, meridian_coverage_baseline, + active_phases, cultural_tags, faction_presence_tiers + Serves: All playstyles (cultural foundation) + + Stage 2: District Skeleton Generation + Outputs: DistrictSkeleton + guarantee audit (11 checks passed) + Includes: historical event modifier pass, LShape causes, cross-district edge connections + Serves: Investigation (surveillance topology), Tycoon (economic nodes), + Social (encounter windows), Politics (power gradient) + + Stage 3: Block Planning + Outputs: 16 BlockSkeletons + edge contracts + era tags with causes + landmark slots + Includes: density alternation enforcement, flavor structure pre-assignment + Serves: All playstyles (physical navigation, density contrast) + +[PHASE 2 — Local Area, On-Demand] + Stage 4: Chunk Fill + Outputs: Tile data, applied flavor structures with NPC-affinity tags + Includes: zone palette, era materials, LOS anchors, facade variation + Serves: All playstyles (spatial readability) + + Stage 5: NPC Instantiation + Outputs: Generated NPCs with 10-axis configs, role assignments, triangles + Includes: NPC affinity from flavor structure types, society profile behavioral mods + Serves: All playstyles (the human texture) + + Stage 6: Scenario Instantiation + Outputs: Active scenarios, entanglement pattern, evidence placement, + economic asymmetry specifics, political power topology + Serves: Investigation (entanglement), Tycoon (economic opportunity), + Social (relationship status), Politics (power configuration) +``` + +--- + +## Open Questions for Round 3 + +### OQ-R2-A: Triangle Purpose Taxonomy +I proposed `triangle_purpose: TrianglePurpose` (investigation/economic/political/social) in `SocialSitePlacement`. This needs formal definition. Tyre to confirm whether this adds implementation complexity, or whether the existing `TriangleTemplate` struct can carry purpose tags. + +### OQ-R2-B: Guarantee Audit as Formal Schema +The 11-check guarantee audit I've proposed should become a validated runtime check, not just a design document item. Every generated `DistrictSkeleton` should serialize its audit results. This supports validation (Tyre's test fixture proposal) and debugging. + +### OQ-R2-C: Maritime and Wilderness Navigation +The Traffic Chokepoint archetype works for all setting geometries. But for wilderness/maritime, the "informal zone" mechanic changes — there's no "maintenance corridor." What's the wilderness equivalent? I propose `terrain_informal_zone` (a geography-defined sheltered space — cave, ravine, hidden cove) that satisfies the same guarantee. This needs Miri's input on what wilderness informal zones look like culturally. + +--- + +*Gestalt — Round 2 complete. Position is clear: the 7 archetypes serve all four playstyles simultaneously; the pipeline is two-phase with a 6-stage internal structure; population ordering question is resolved (sequential, no feedback loop); the guarantee audit is the enforcement mechanism. Ready for Round 3 convergence.* diff --git a/docs/workshops/generator-architecture/gestalt-round3.md b/docs/workshops/generator-architecture/gestalt-round3.md new file mode 100644 index 000000000..be078bc1e --- /dev/null +++ b/docs/workshops/generator-architecture/gestalt-round3.md @@ -0,0 +1,870 @@ +# Generator Architecture Workshop — Round 3: Gestalt + +**Role:** Systems Design / Gameplay Loop Requirements +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## Framing: Five Lead Directives, One Convergence Document + +Round 3 has five directives: assassin playstyle, universal-vs-conditional guarantees, destructible boundaries, vertical scale, and dynamic world modification. Plus Qatux flagged my own open question (OQ-R3-B: triangle purpose taxonomy) and three overlapping tier concepts that need reconciliation. + +Let me break down what each directive means mechanically, then converge everything into a final pipeline statement. + +--- + +## 1. Assassin as Full Playstyle Lens + +Let me map the assassin's spatial needs before touching the archetype table. + +**What the assassin is doing:** Pre-operation intelligence gathering → position staging → execution window → egress. The assassin is an investigator first (must understand the target's pattern), a political actor second (understands whose contract they're operating under), and a precision combatant third. Mechanically, what distinguishes assassination from combat is **timing and position** — not "can I win a fight" but "can I be in this specific place at this specific moment without being observed." + +**The assassin's unique spatial requirements:** + +| Need | Spatial expression | +|---|---| +| **Elevated vantage** | Any position 1+ z-level above a Traffic Chokepoint with LOS coverage downward | +| **Timing window** | A period in the target area's day-cycle when NPC/observer density is reduced enough to act | +| **Crowd anonymity** | A Social Hub or Encounter Corridor dense enough to blend into — the assassin needs to be unremarkable | +| **Staging approach** | A route from the district edge to the target area that avoids institutional observation (Meridian dead zones, maintenance routes, unofficial paths) | +| **Egress multiplicity** | At least 2 independent exits from the target zone that don't share a chokepoint — if one is cut off, the other remains | +| **Pattern intelligence** | NPCs on the target's known route — the assassin needs to confirm the schedule before committing | +| **No-door access route** | At least one path to the target area that doesn't pass through an institutional access point (guard, checkpoint, scan) | + +Now the updated archetype table. Notice how every assassination archetype serves multiple playstyles — the SPACE is universal, only the USE differs. + +### 1.1 Updated 7 Spatial Archetypes — Assassin Column Added + +| Archetype | Definition | Investigation | Tycoon | Dating Sim | Political | **Assassin** | +|---|---|---|---|---|---|---| +| **Traffic Chokepoint** | Spatial bottleneck where significant NPC flow passes and is observable from an adjacent fixed position | Surveillance of movements and tells | Control trade flow leverage | Serendipitous encounter, reliable find | Campaign/voter presence, speechmaking | **Timing window — target must pass here; vantage position nearby** | +| **Informal Zone** | Degraded institutional coverage, low ambient traffic, unofficial use — grey-area space | Quiet zone, dead drops, private meetings | Grey market exchange, unofficial trade | Private encounter, trysts, earned intimacy | Back-channel negotiation, away from faction eyes | **Staging approach — pre-op cache, equipment stash, unobserved waiting position** | +| **Social Hub** | High NPC density, social mixing, multiple access tiers coexisting — the gathering place | Rapport building, information gathering | Networking, trade leads, rumor sourcing | Romance venue, repeated encounter, public ritual | Influence gathering, political reading | **Crowd cover — anonymity in numbers, pattern intelligence via overhearing** | +| **Institutional Space** | Formal authority presence, access-tier-enforced, zone palette signals power | Official authority access, procedural leverage | Licensing, formal contracts, regulatory navigation | Formal encounter context (job interviews, official appointments) | Power center — the space authority controls | **Security architecture to map, access points to exploit or avoid** | +| **Insider Space** | Non-public access, social proof required, closed group — the back room | Ring access visibility, trusted social network | Guild/cooperative insider, exclusive supplier | Close friend group, earned intimacy depth | Faction HQ, party inner circle | **Target's personal protection circle — and the gap where access might exist** | +| **Economic Node** | Visible economic activity, pricing signals, transaction infrastructure | Evidence trail — money follows crime | Primary profit opportunity, arbitrage, deals | Shared activity creates natural encounter | Economic leverage over actors who control resources | **Contract source (who pays for the job), payment receipt, opposition's funding** | +| **Encounter Corridor** | NPC daily movement route, traversal path, corridor with observable foot traffic | NPC observation, pattern detection | Supply chain visibility, route control | Daily routine overlap — the street the target of affection always takes at 3pm | Visibility territory, patrol routes, political march | **Target's known route — where and when they're predictably exposed** | + +### 1.2 Assassin-Specific Spatial Guarantees + +The 7 archetypes don't fully cover assassin requirements. The assassin needs additional guarantees that don't reduce to any single archetype: + +**Guarantee A-1: Elevated Vantage Position** +Every Full-complexity district must contain at least one position at z-level 1 or above that has a clear LOS cone covering the primary Traffic Chokepoint. This isn't a separate zone — it's a spatial property of the chokepoint's adjacent structures. + +*Other playstyle uses:* Investigation (counter-surveillance vantage), Dating Sim (Ozzie's "vertical surprise — going up when I didn't expect to"), Political (speaking platform that can address the crowd). + +**Guarantee A-2: Egress Multiplicity** +Every Entry Point (district boundary access point) must have at least 2 independent egress routes that don't share a secondary chokepoint. "Independent" means: if one route is blocked by an NPC or physical obstacle, the other remains viable. This is a connectivity property of the Encounter Corridor graph. + +*Other playstyle uses:* Investigation (if blown, you need another way out), Tycoon (redundant supply routes), Dating Sim (the ability to leave a scene with dignity). + +**Guarantee A-3: Temporal Opacity Window** +Every Full-complexity district must have at least one period in each day-cycle (D-031) where the Traffic Chokepoint's observer density drops below the "crowd cover" threshold. Mechanically: a phase of the day when the chokepoint has fewer than N active NPCs in observation range. This is determined at NPC schedule generation time. + +*Other playstyle uses:* Investigation (dead-of-night investigation access), Tycoon (off-hours deals), Dating Sim (Ozzie's ritual gathering — you know they'll be here at 3pm). + +**Guarantee A-4: Non-Institutional Access Route** +At least one path from district entry to the Social Hub must not pass through any access tier above `Semi-Private`. A district where every path to the gathering place requires crossing an institutional checkpoint is inhospitable to any non-credentialed character — including all playstyles. + +*Note: This is not assassination-specific — it's an accessibility guarantee that serves all non-credentialed characters.* + +--- + +## 2. Universal vs. Conditional Guarantee System + +**The lead directive correction:** Not every place serves every playstyle. A farmstead doesn't need an assassination sightline. + +This is the right correction, and it resolves a design tension that's been implicit since Round 1. Let me build the formal model. + +### 2.1 Two-Axis Classification + +Every guarantee is classified on two axes: + +**Axis 1: District Complexity Threshold** +- `Universal`: applies to all inhabited districts regardless of complexity +- `Full-only`: applies to Full-complexity districts only +- `Conditional`: applies when specific district parameters (terrain, drama density, playstyle context) warrant + +**Axis 2: Terrain-Agnostic vs. Terrain-Aware** +- `Terrain-agnostic`: the guarantee applies regardless of setting geometry (Station/Urban/Rural/Maritime/etc.) +- `Terrain-aware`: the guarantee has a terrain-specific expression (a Traffic Chokepoint in wilderness is a mountain pass, not a corridor) + +### 2.2 The Guarantee Tiers + +**TIER 1: Universal Guarantees (all inhabited districts, any terrain)** + +These are not negotiable even for a Minimal-complexity backwater. They're properties of any space where humans live and move. + +| Guarantee | Rationale | Terrain expression | +|---|---|---| +| **Social Hub** | Any inhabited space has somewhere people gather. Even in a village of 8. | Station: bar. Urban: market. Rural: community hall. Maritime: the dock. Wilderness: the campfire. | +| **Informal Zone** | Everywhere humans live has grey-area space that institutional authority doesn't fully penetrate. | Station: maintenance corridor. Urban: back alley. Rural: the back of the barn. Maritime: below deck. Wilderness: the whole thing. | +| **Encounter Corridor** | Any connected place has routes people use regularly. Even a settlement of 8 has a path people walk every day. | Station: main transit spine. Urban: high street. Rural: farm road. Maritime: the dock approach. Wilderness: trail. | + +**TIER 2: Full-Complexity Guarantees (Full-complexity districts, terrain-aware)** + +These require sufficient population and infrastructure to produce. They apply when the district is at `ComplexityTier::Full`. + +| Guarantee | Terrain-agnostic? | Terrain-specific expression where needed | +|---|---|---| +| **Traffic Chokepoint** | Terrain-aware | Urban/Station: architectural chokepoint. Non-urban: natural bottleneck (mountain pass, harbor mouth, river ford, valley entrance) | +| **Institutional Space** | Terrain-aware | Urban/Station: formal building with access control. Rural: may be absent (zero faction presence) — if absent, the guarantee is waived. Wilderness: always absent. | +| **Insider Space** | Terrain-agnostic | Exists wherever there is a community. The "insider" social geography scales with population size. | +| **Economic Node** | Terrain-aware | Urban/Station: commercial infrastructure. Rural: market day, granary, water allocation point. Maritime: harbor trading post. Wilderness: resource extraction site (not economic in the formal sense — gameplay differently). | + +**TIER 3: Conditional Guarantees (apply based on playstyle-context and complexity)** + +These don't apply universally — they activate when the district's complexity, drama density, and active playstyle warrant them. + +| Guarantee | Condition | +|---|---| +| **Elevated Vantage Position** (A-1) | Full-complexity districts with vertical architecture (z_levels ≥ 2) | +| **Egress Multiplicity** (A-2) | Full-complexity districts; applies in all terrains but particularly critical in enclosed settings (Station, Maritime) | +| **Temporal Opacity Window** (A-3) | Full-complexity districts with defined NPC schedules | +| **Economic Asymmetry Signal** | Full-complexity districts with active economic function (not subsistence/transit-only) | +| **Temporal Encounter Window** | Full-complexity districts with active social sites and D-031 day-phase integration | +| **Power Gradient Visibility** | Full-complexity districts with faction presence tier ≥ Standard | + +### 2.3 The Guarantee Audit: Conditional Logic + +The 11-check audit from Round 2 needs to be conditional-aware. Here's the revised `GuaranteeAuditResult` logic: + +``` +For each district being audited: + +1. Check TIER 1 (all inhabited districts): + [ ] Social Hub present? + [ ] Informal Zone present? + [ ] Encounter Corridor present? + +2. IF complexity == Full OR Moderate: + [ ] Traffic Chokepoint present (terrain-appropriate form)? + [ ] Insider Space present? + +3. IF complexity == Full: + [ ] Institutional Space present? (WAIVED if faction_presence == Absent everywhere) + [ ] Economic Node present? (WAIVED if economic_function == Subsistence or Wilderness) + +4. IF complexity == Full AND z_levels >= 2: + [ ] Elevated Vantage present? + +5. IF complexity == Full AND npc_schedule_density >= threshold: + [ ] Temporal Opacity Window exists in at least one day-phase? + +6. IF complexity == Full AND faction_presence >= Standard: + [ ] Power Gradient Visibility present? + +7. IF complexity == Full AND economic_function is not Subsistence/Wilderness: + [ ] Economic Asymmetry Signal present? +``` + +A Minimal-complexity farmstead district: only 3 checks. A Full-complexity urban hub: up to 11 checks. The audit scales with district class. **No false positives from applying urban guarantees to rural fields.** + +### 2.4 The Assassin Lens as a Superposition + +Critically: the assassin doesn't add new spaces to the district. The assassin reads EXISTING spaces differently. The Encounter Corridor is the target's known route. The elevated position above the Traffic Chokepoint is the vantage. The Informal Zone is the staging ground. + +The generator doesn't tag spaces "this is for assassins." The generator produces spaces with certain properties (elevated, overlooking chokepoint, low observation) and the assassin system identifies which spaces satisfy which operational requirements. This is Miri's core insight: one information landscape, multiple lenses. + +**Implication for the guarantee audit:** The assassin-specific guarantees (A-1, A-2, A-3) are NOT new checks to add to the 11. They're DERIVED PROPERTIES from the existing spatial configuration. If the Full-complexity district has an elevated vantage position, the assassin has a sightline. If it has route multiplicity, the assassin has egress options. The audit verifies spatial properties; the playstyle systems decide what to do with them. + +--- + +## 3. Destructible Boundaries: Generator Rules + +**The question:** What does the generator put behind a wall the player destroys? + +Let me break down what "behind a wall" means in a top-down tile grid. + +### 3.1 Every Tile Is Pre-Generated + +The key insight: the game is a top-down tile grid. A 64×64 chunk is a 64×64 array of tiles. Every tile in that array exists in the data structure, whether the player has access to it or not. **Walls don't create holes in the tile data — they're a tile TYPE that prevents movement and blocks LOS.** + +"What's behind the wall" is already in the `ChunkData`. The question is what TILE TYPE is on the far side of the wall tile, and whether that tile has ever been prepared for player-facing content. + +### 3.2 Three Wall-Behind States + +The generator must produce one of three states for every wall tile's reverse side: + +**Type 1: Structural Void** +`TileBehind::StructuralFill` + +Behind this wall is structural material — load-bearing concrete, pressure bulkhead, foundation column. No space. Blowing it open produces rubble and debris at best; at worst, triggers a structural instability cascade affecting adjacent tiles. + +Generator marks these at block planning time based on building structural logic: +- Outer walls of a building are always `StructuralFill` on the exterior face +- Load-bearing interior walls (every 4th wall in a standard building grid) are `StructuralFill` +- Pressure bulkheads in station environments (separating pressurized from vacuum) are `StructuralFill` + +```rust +enum TileBehindState { + /// No space. Structural material. Breaching is possible but triggers consequences. + StructuralFill { + material: StructuralMaterial, + stability_impact: f32, // how much this wall contributes to building structural integrity + }, + /// A real room that has no player-facing access route in normal play. + /// Pre-generated at chunk fill time with full content (may be empty, may have contents). + HiddenRoom { + room_seed: u64, + fill_tag: String, // what kind of room this is + }, + /// Small interstitial void between structures — not a room, not structural. + /// A 1-4 tile gap. Can be entered, has nothing in it. + Interstitial { + width_tiles: u8, + }, +} +``` + +**Type 2: Hidden Room** +`TileBehind::HiddenRoom` + +Behind this wall is a real room that the chunk fill has generated as a complete space — floor, potential contents, potential NPC spawn — but which has no door or access point opening onto the player's side. The room is pre-generated whether the player ever reaches it or not (idempotent from seed). + +This is the mechanical foundation for Ozzie's "place I'm not supposed to be." The generator guarantees: + +> **Every Full-complexity district must contain at least one Hidden Room zone accessible only via breach (no door, no vent, no official access point on the player's side of the wall).** + +Generator implementation: +- At chunk fill time, `access_point_count: u8 = 0` marks a `ChunkFillSpec` as breach-only +- These are generated with full content — they're rooms that happen to have no door +- Contents are seeded based on the room's social context: a private office adjacent to a Institutional Space might contain files; a storage room adjacent to an Informal Zone might contain contraband; a maintenance junction might contain infrastructure controls + +Hidden rooms are how the generator produces secrets that aren't marked "this is a secret." They're just rooms without obvious doors. Players who blow through walls find them; players who don't, don't. No quest marker. Just space with contents and no apparent route. + +**Type 3: Interstitial Void** +`TileBehind::Interstitial` + +The gap between two buildings that's 1-4 tiles wide — not a room, not structural. These are produced by the anti-grid techniques (Araminta's setback variation, irregular building footprints). They have nothing in them by default, though NPC procedural placement might use them as informal routes. + +### 3.3 The Player-Facing Contract + +The generator's destructible boundary contract: + +1. **All tile data pre-exists.** Chunk fill generates EVERYTHING in the chunk, including rooms with no player-facing access points. Breaching a wall doesn't require on-the-fly generation. + +2. **Structural walls are marked.** `TileFlag::LoadBearing` identifies walls that cause structural cascades when destroyed. The gameplay system reads this flag to determine breach consequences. + +3. **Hidden rooms have real content.** The generator doesn't produce empty "dead space" behind walls (Ozzie's Generation Sin #3). If the space exists, it pays rent — even if rent is "a small maintenance junction with spare parts and a scratched message someone left on the wall." + +4. **Breach access is a first-class access tier.** `AccessTier::BreachOnly` — a space that requires destructive entry. This is distinct from `Restricted` (which is passable with credentials) and `Insider` (which is passable with social trust). + +### 3.4 Guarantee: At Least One Breach-Only Space per Full District + +> **Guarantee: Every Full-complexity district must contain at least one zone classified `AccessTier::BreachOnly` — a space that has no non-destructive access route.** + +This is the generator's structural implementation of Ozzie's Round 1 demand: "I need to find somewhere that feels like I wasn't meant to find it." The generator doesn't flag these as "secrets." It just makes rooms without doors and leaves the player to find them. + +--- + +## 4. Vertical Scale: Multi-Z Architecture + +**The question:** How does a 50-floor skyscraper emerge from the generator? The current 3 z-level model works for station environments. What about planet-side cities with tall buildings? + +### 4.1 What Vertical Means in a Top-Down Game + +First, the mechanical reality: this is a top-down 2D game. The player never sees a cross-section of a building. They see ONE horizontal slice at a time — the floor they're on. Transitioning between floors means traversing a stairwell or lift (which is effectively a corridor connecting two separate map states). + +So "50 floors" doesn't mean the player sees 50 floors simultaneously. It means there are 50 distinct horizontal-slice states the player can transition between via vertical corridors. Each floor is a 2D map. Vertical scale is about the NUMBER of distinct horizontal states and the CONNECTIVITY between them. + +### 4.2 Z-Bands: Grouping Floors into Generator Units + +The generator doesn't need to model every floor independently at the skeleton stage. It models **z-bands** — groups of floors with similar social function. The social content changes at z-band boundaries, not floor-by-floor. + +A 50-floor skyscraper might have 4 z-bands: +- **Z-band 0** (floors 1-5): Ground-level commercial/public. Full public access. +- **Z-band 1** (floors 6-20): Office/institutional. Semi-private access tier. +- **Z-band 2** (floors 21-45): Upper office/restricted functions. Insider/restricted access tier. +- **Z-band 3** (floors 46-50): Executive/penthouse. Restricted/breach-only access tier. + +Each z-band is generated as an independent horizontal layer with its own: +- `ZoneType` and zone palette +- Access tier (the vertical gradient runs highest-to-most-restricted as you go up) +- NPC population subset from the district roster +- Social sites (a z-band 0 might have a lobby bar; z-band 2 might have a boardroom social site) +- Internal floor layout (Phase 2 chunk fill generates each floor's tiles) + +### 4.3 Multi-Block Reservation for Tall Structures + +Tall buildings use `MultiBlockReservation` — the same mechanism that handles large horizontal structures — extended to include `vertical_extent`: + +```rust +struct MultiBlockReservation { + /// Which blocks this structure occupies (horizontal footprint) + block_coords: Vec<(u8, u8)>, + + /// NEW: Vertical extent in z-bands + z_band_count: u8, + + /// NEW: Height in visual/sim floors (for rendering and collision) + floor_count: u8, + + /// Structure type + structure_type: MultiBlockStructureType, + + /// Social site hosted (may span multiple z-bands) + hosted_sites: Vec<SocialSiteId>, + + /// NEW: Per-z-band zone assignment + z_band_zones: Vec<ZoneDefinition>, + + /// NEW: Vertical corridor spines (lifts, stairs, shafts) + vertical_corridors: Vec<VerticalCorridorSpec>, +} + +struct VerticalCorridorSpec { + /// Which blocks contain this corridor (may be 1 block or span multiple) + block_coords: Vec<(u8, u8)>, + + /// Which z-bands this corridor connects + z_bands_connected: Vec<u8>, + + /// Access tier required to use this corridor + access_tier: AccessTier, + + /// Type (lift, stairwell, service shaft, emergency escape) + corridor_type: VerticalCorridorType, +} +``` + +### 4.4 Vertical Access Tier Gradient + +The access tier gradient that Araminta defined running inward from street face — public → semi-private → restricted — has a VERTICAL analog: + +**Vertical gradient:** ground level = most public; upper levels = most restricted + +This is a spatial law that players understand intuitively (penthouses are exclusive; lobbies are open). The generator enforces it: `AccessTier` must be monotonically non-decreasing as z-band index increases, with at least one tier step between z-bands. + +The bottom z-band can be `Public`. The top z-band will typically be `Restricted` or `BreachOnly`. + +**Assassin implication:** Getting to the top of a tall building is an access-tier challenge. The vertical corridor is a chokepoint. The lift is a bottleneck. The stairwell is monitored. Getting UP is the operational problem, more than getting to the target once you're there. + +### 4.5 The Roof as Mandatory Discovery Zone + +> **Guarantee: Every tall structure (z_band_count ≥ 3) must have a roof zone classified `AccessTier::Insider` or `AccessTier::BreachOnly` — accessible by a non-obvious route.** + +The roof isn't a separate district. It's the top of the `MultiBlockReservation`, generated as an additional z-band with open-sky tile properties. The roof: +- Has dramatically extended LOS (no walls, elevated position over surrounding blocks) +- Is the highest vantage point in the district +- Has no official occupants (its own `HiddenRoom` equivalent at building scale) +- Must be reachable — but the route is non-obvious (service access, emergency hatch, window ledge) + +The roof is Ozzie's "vertical surprise that reorients my mental map" at building scale. + +### 4.6 What Stays the Same + +The 4-level spatial hierarchy (chunk/block/district/system) doesn't change. Tall buildings are multi-block, multi-z-band structures within an existing district. The chunk size (64×64 sim tiles) doesn't change — each floor of a building is composed of chunks. The streaming model doesn't change — the player's 3×3 loading grid operates in the current z-band, with adjacent z-bands cached. + +--- + +## 5. Dynamic World Modification: Delta Layer Model + +**The question:** Gas main explosion in a district the player already visited. Should the generator re-render affected chunks? + +### 5.1 Two Sources of World State + +The core architectural distinction: + +- **Generator State**: The seed-derived, deterministic foundation. `PreparedDistrict` + `ChunkData`. This is IMMUTABLE after generation. Its determinism guarantee is the game's foundation. +- **World State Deltas**: Post-generation modifications. Events, player actions, gameplay consequences. These live in a separate structure layered on top of Generator State. + +The gas explosion doesn't change what the generator produced. It creates a `WorldStateDelta` that describes the explosion's effect. When rendering or gameplay processes chunk (x,y), it applies: + +1. Generator `ChunkData` (base state, always deterministic from seed) +2. All `WorldStateDelta` entries for this chunk (ordered by tick timestamp) + +Result: the chunk looks like the generated version plus the applied deltas. **The generator never re-runs. The delta layer carries the modification.** + +### 5.2 The Delta Structure + +```rust +struct WorldStateDelta { + /// Which chunk this delta affects + chunk: ChunkCoords, + + /// When this happened (simulation tick) + timestamp: SimTick, + + /// What changed + delta_type: DeltaType, + + /// Source of the change (gameplay consequence, NPC action, Tier 1 module event, etc.) + source: DeltaSource, +} + +enum DeltaType { + /// Structural damage from explosion, combat, decay + StructuralDamage { + tiles: Vec<TileCoord>, + damage_level: DamageLevel, // Scorched, Damaged, Destroyed, Collapsed + }, + + /// A wall has been breached (player or NPC action, explosion) + WallBreached { + wall_tile: TileCoord, + breach_type: BreachType, // Blown, Forced, Cut + }, + + /// A door's state has changed persistently + DoorStateChanged { + door_id: DoorId, + state: DoorState, // Open, Closed, Locked, Breached, Destroyed + }, + + /// An object has been added or removed + ObjectModified { + position: TileCoord, + modification: ObjectModification, // Added, Removed, Damaged, Moved + object_id: ObjectId, + }, + + /// A tile's traversability changed (collapse reveals new space, explosion creates gap) + TileTypeChanged { + position: TileCoord, + new_type: TileType, + }, + + /// An access tier changed due to gameplay events (lockdown, faction capture) + AccessTierChanged { + zone: ZoneId, + new_tier: AccessTier, + expires_at: Option<SimTick>, // NULL = permanent + }, + + /// An NPC position has been permanently altered (killed, arrested, moved away) + NpcRemoved { + npc_id: NpcId, + reason: NpcRemovalReason, + }, +} +``` + +### 5.3 Handling Large-Scale Events + +For minor events (one gas explosion, a door being kicked in): the `WorldStateDelta` layer handles it cleanly. Tens to hundreds of tile modifications. Lightweight. + +For major events (fire that guts an entire block, a faction takeover that completely rebuilds a zone): the delta layer becomes expensive. Many thousands of tile modifications. For these cases: + +**Soft Re-Generation**: Re-run Phase 2 for affected chunks with an event-modified seed: + +``` +event_chunk_seed = original_chunk_seed XOR event_seed +``` + +This produces a consistent, deterministic "post-event" state. The new chunk state is: +- Different from the generator's original output (the event happened) +- Still deterministic (reproducible from seed + event record) +- Cached and saved as a new `ChunkData` snapshot + +The save file maintains a record of which chunks have been "soft re-generated" and their event-modified seeds. This preserves: +- **Determinism**: same events → same post-event state +- **Persistence**: player returns to find the same damage +- **Memory**: the player can understand what was there before (NPCs remember; environmental evidence remains) + +### 5.4 World Modification as Gameplay Consequence + +The delta layer is not just for disaster events. It handles the full range of world modification: + +| Player action | Delta type | +|---|---| +| Kick down a door | `WallBreached` or `DoorStateChanged` | +| Kill an NPC | `NpcRemoved` | +| Plant evidence | `ObjectModified::Added` | +| Cause an explosion | `StructuralDamage` + `WallBreached` x N | +| Commission faction clears a district | `AccessTierChanged` (district-wide) | +| Smuggling ring abandons a stash location | `ObjectModified::Removed` x N + `AccessTierChanged` | + +The game's consequence systems write `WorldStateDelta` entries. The rendering and gameplay systems read them when processing chunks. **The generator never needs to know that any of this happened.** + +### 5.5 What the Generator Guarantees vs. What the Delta Layer Guarantees + +| Property | Generator guarantees | Delta layer maintains | +|---|---|---| +| Spatial structure | Always available | May be modified by events | +| NPC roster | Generated deterministically | May shrink as NPCs are removed | +| Access tiers | Defined by district skeleton | May change due to faction events | +| Room contents | Generated at chunk fill time | May be modified by player/NPC actions | +| Tile data | Deterministic from seed | May be overwritten by delta events | + +The generator produces the world as it was. The delta layer describes what has happened to it since. The player experiences the composition. + +--- + +## 6. Reconcile SignificanceTier / ComplexityTier / DramaDensity + +Qatux correctly flagged these three overlapping concepts. My Round 2 `SignificanceTier` introduced a redundancy. Here is the clean two-parameter model. + +### 6.1 The Two-Parameter Model + +**I am retiring `SignificanceTier`.** It conflates two orthogonal properties and creates confusion with `ComplexityTier`. Here's the correct model: + +| Parameter | Type | When Set | What It Controls | +|---|---|---|---| +| **`ComplexityTier`** | Static (generator) | Phase 1 Pre-Pipeline | CAPACITY: what the generator produces | +| **`DramaDensity`** | Dynamic (storyteller) | Gameplay, Storyteller | UTILIZATION: what the Storyteller fires | + +These answer different questions: +- `ComplexityTier` answers: "What kind of district is this?" +- `DramaDensity` answers: "What is happening in this district right now?" + +### 6.2 ComplexityTier (Static, Generator-Determined) + +```rust +enum ComplexityTier { + /// Full social architecture. 7+ archetypes. 20-80+ NPCs. + /// All Tier 1 and Tier 2 guarantees apply. + /// Supports: all playstyles at full depth. + Full, + + /// Moderate social architecture. 3-5 archetypes. 8-20 NPCs. + /// Tier 1 guarantees + Traffic Chokepoint + Insider Space. + /// Supports: all playstyles at reduced depth. + Moderate, + + /// Minimal social architecture. 1-2 archetypes. 1-8 NPCs. + /// Tier 1 guarantees only. + /// Supports: background world texture; Ozzie's "density contrast" filler. + Minimal, + + /// No social architecture. 0 archetypes. 0 NPCs. + /// No guarantees. Pure terrain and traversal. + Empty, +} +``` + +**ComplexityTier is determined at Phase 1 Stage 0 (Pre-Pipeline).** It's derived from: +- World network position (hub nodes → Full; remote periphery → Minimal/Empty) +- Setting geometry (Urban/Station → usually Full; Wilderness → usually Empty) +- Storyteller pre-seeding (the Storyteller can override the default for a seed's purposes) + +### 6.3 DramaDensity (Dynamic, Storyteller-Controlled) + +```rust +/// Drama Density: what the Storyteller is firing in this district right now. +/// This is NOT a generator parameter — it's a runtime game state. +enum DramaDensity { + /// No Tier 1 modules active. Social fabric stable. + /// Backwater guarantee: the Storyteller will not fire modules here. + Zero, + + /// One Tier 1 module active at low intensity, or mundane triangle fully active. + Low, + + /// One Tier 1 module + active mundane triangle pressure + economic tension. + /// Sova Transit District in steady state. + Medium, + + /// Multiple modules active, contested faction presence, elevated pressure. + High, + + /// Maximum: multiple modules, faction conflict, historical disruption, elevated entanglement. + /// Should be rare. Must feel rare. The Storyteller deploys this sparingly. + Flashpoint, +} +``` + +### 6.4 The Critical Relationship: Capacity vs. Utilization + +**The Storyteller cannot fire DramaDensity above the ComplexityTier's capacity ceiling.** + +| ComplexityTier | Maximum DramaDensity | Why | +|---|---|---| +| Full | Flashpoint | Has the population density and social infrastructure to support it | +| Moderate | High | Has enough NPCs for conflict, but not full-ring infrastructure | +| Minimal | Low | Very few NPCs; even a single active module strains the social fabric | +| Empty | Zero | No NPCs, no modules possible | + +A `Minimal`-complexity village can have `Low` DramaDensity — a single personal drama, a domestic dispute with outsider consequences. It cannot have a full political crisis (no faction infrastructure) or a major smuggling ring (too few people to sustain it). The Storyteller respects this ceiling. + +**The "false backwater" (Nigel's concept) is now expressible:** + +> District: `ComplexityTier::Minimal`, `DramaDensity::Zero` — appears to be a quiet unremarkable stop. +> BUT: the district is a node in a Tier 1 module (ring transit route) that the Storyteller has flagged as active but not yet surfaced in this location. +> RESULT: The tycoon who investigates finds the module. The detective who passes through and doesn't look, doesn't. Same ComplexityTier. Same DramaDensity. Different player perception. + +The false backwater doesn't require high complexity OR active drama. The Tier 1 module exists at a meta-level; the district itself is genuinely quiet. The module activates in response to the player's investigative actions, not as a property of the district. + +### 6.5 Final Disposition + +| Round 2 concept | Round 3 status | +|---|---| +| `SignificanceTier` (Gestalt) | **RETIRED.** Absorbed into ComplexityTier + DramaDensity + network position metadata | +| `ComplexityTier` (Tyre) | **RETAINED.** Static, generator-determined capacity parameter | +| `DramaDensity` (Nigel) | **PROMOTED.** Dynamic, storyteller-controlled utilization parameter — formally added to game state | + +--- + +## 7. Triangle Purpose Taxonomy (OQ-R3-B: Resolved) + +This is my open question from Round 2. Here's the resolution. + +### 7.1 The Purpose Enum + +```rust +/// Why does this triangle exist, and which playstyle activates it? +/// Note: a triangle can serve multiple purposes simultaneously. +enum TrianglePurpose { + /// Three NPCs in conflicting interests around hidden criminal/grey activity. + /// Activated by: investigation-related Tier 1 modules, detective archetype engagement. + Investigation, + + /// Three NPCs in conflicting economic interests (market, trade, resources, contracts). + /// Activated by: tycoon playstyle interaction, economic Tier 1 modules. + Economic, + + /// Three NPCs in romantic, family, or social competition. + /// Activated by: dating sim playstyle interaction, social-drama modules. + /// SAME mechanical structure as Investigation triangle — different content tags. + Social, + + /// Three NPCs in institutional power contest (positions, authority, faction allegiance). + /// Activated by: political playstyle interaction, faction modules. + Political, + + /// Three NPCs structurally relevant to an assassination context: + /// the target, their protector/guardian, and the informant or witness. + /// Activated by: contract modules, assassination target proximity. + Tactical, + + /// Background social tension — never "activated" as player-facing primary drama. + /// Workplace rivalries, family disputes, neighborhood dynamics. + /// The 50% mundane from D-029. ALWAYS present in any inhabited district. + /// Multiple playstyles can NOTICE these but they are not primary drama drivers. + Mundane, +} +``` + +### 7.2 Rules for Triangle Composition per District + +- Every Full-complexity district: at minimum 2 triangles, at least 1 `Mundane` +- Every Full-complexity district: at minimum 1 non-Mundane triangle whose purpose matches the district's primary gameplay context (derived from district_type and society_profile) +- Cross-template triangles (D-024): can span purposes (a `Social` triangle can have an `Economic` dimension — the romantic rival is also a business competitor) + +### 7.3 Tactical Triangle and Assassination Gameplay + +Every potential assassination target NPC is the central node of a `Tactical` triangle: +- **Node 1 (Target)**: the NPC with the contract on them +- **Node 2 (Protector)**: whoever guards/monitors/knows the target's movements — could be official security, a close friend, a suspicious colleague +- **Node 3 (Informant/Witness)**: someone who has useful information about the target's pattern, OR someone who might witness and report the act + +The generator guarantees: if a Tier 1 module with contract assassination potential is placed in a Full-complexity district, that district's triangle pool contains a `Tactical` triangle appropriately configured. + +**Tyre's implementation question:** Does `TrianglePurpose` add meaningful complexity? My assessment: it's a simple `Vec<TrianglePurpose>` field on `TriangleTemplate`. The scenario instantiation system already needs to know which triangles to activate for a given module. This tag just makes that lookup explicit rather than inferential. + +--- + +## 8. The Grid Breathing: A Gameplay Position + +Ozzie is asking whether the block grid can rotate, whether streets can curve, whether two adjacent districts can have different orientations. This is primarily Tyre's technical question (D-094 is his to modify or defend). But from a gameplay systems perspective, here is my position: + +**Araminta's seven anti-grid techniques are necessary but not sufficient.** + +Here's why they're necessary: hiding the grid through visual means is cheap and effective for most players most of the time. Diagonal connectors, irregular setbacks, angled infrastructure, light territories — these work. I believe in them. + +Here's why they're not sufficient alone: Ozzie will feel the skeleton. She's right. A systematic player who maps the district on paper will eventually see the 128m block grid. The visual camouflage produces "natural-feeling irregularity" within the grid, not "natural-feeling irregularity OF the grid." + +**My recommendation to Tyre (for his Round 3):** The minimum viable intervention is not full non-rectilinear blocks — that's a major architectural change. The minimum viable intervention is: + +1. **District-level rotation**: Allow districts to be placed at 45° to each other. The grid IS a grid, but adjacent districts can orient differently. A quarter-turn between two adjacent districts produces street angles that feel geological when the streets meet. + +2. **Organic district edge**: Rather than a straight-line boundary between two districts, allow the boundary to follow a jagged line (within 1-2 block tolerance). The transition strip (Tyre's Round 2 solution) already gives us 2 boundary blocks of "neither district" — let that boundary zigzag rather than run straight. + +These two interventions don't change the internal block grid. They change how grids MEET, which is where the visual seam is most dangerous. Ozzie is right that the seam will eventually show — but the seam appears most clearly at district boundaries, and that's what the transition strip is for. + +**What I'm not asking Tyre to do:** Full WFC-style non-rectilinear districts with curved streets. That's V0.5+ territory if it's ever worth the implementation cost. The question for V0.1-V0.3 is whether the visual techniques plus boundary-level interventions get us to "player doesn't feel the grid on the 5th station." I believe they do with the boundary improvements. + +--- + +## 9. Final Pipeline Architecture: Canonical Summary + +Convergence from three rounds. This is the definitive pipeline statement. + +``` +═══════════════════════════════════════════════════════════════════ +PRE-PIPELINE (Static World Architecture) +═══════════════════════════════════════════════════════════════════ + +Master Seed (single, from Tyre §3) + ↓ +System Generation + derives: star type, world count per system, gate connections + ↓ +Per-World Significance Assignment + ├── ComplexityTier: Full / Moderate / Minimal / Empty + │ (derived from network position, setting geometry, world role) + ├── SettingGeometry: Station / Urban / Agricultural / Wilderness / + │ Water / Transitional / Orbital + └── DramaDensity ceiling: constrained by ComplexityTier + (DramaDensity itself is set by Storyteller at runtime) + + +═══════════════════════════════════════════════════════════════════ +PHASE 1: WORLD PREP (Background, Async, ~50-500ms per district) +═══════════════════════════════════════════════════════════════════ + +Stage 1: Society Profile Assembly + input: system_seed, world network position, SettingGeometry + output: SocietyProfile (serde YAML → Rust struct, Tyre §4) + produces: heritage blend, economic function/pressure, drift stage, + faction presence, philosophical alignment, meridian coverage + skipped for: Wilderness/Empty districts (no society) + ↓ + +Stage 2: District Skeleton Generation + input: district_seed, SocietyProfile, ComplexityTier, SettingGeometry + output: DistrictSkeleton (canonical struct — Tyre + Gestalt composite) + produces: + - Zoning (block types, access tiers) + - Social site placement (D-025 templates, triangle topology) + - Triangle pool (with TrianglePurpose tags — §7) + - Multi-block reservations (horizontal + vertical extent — §4) + - Vertical corridor spines (for tall structures — §4) + - Corridor spines (Encounter Corridors + access points) + - Zone palette assignment (Araminta's zone types) + - Boundary descriptors (for edge bleed — Tyre §2) + - Guarantee audit: conditional on ComplexityTier (§2) + - Breach-only zones: at least 1 in Full-complexity (§3) + ↓ + VALIDATE spatial prerequisites (Tyre §6.1): + check guarantees for this ComplexityTier + SettingGeometry + adjust zoning if prerequisites not met + check breach-only zone guarantee + ↓ + +Stage 3: Block Planning + input: DistrictSkeleton, district_seed + output: BlockPlan per block (Tyre §1.3) + produces: + - ChunkLayout (merge strategy, quarter layout) + - Era assignment + era_modifications (with era_cause — §R1 resolved) + - Edge contracts (Tyre's Option A) + - Quarter form × function assignments (Araminta + Nigel composite) + - TileBehindState for all walls in ChunkFillSpec (§3) + - For tall structures: z_band_zones, z_band_access_tiers + ↓ + +Stage 4: NPC Population + input: DistrictSkeleton (NPC role slots), district_seed + output: NpcRoster (Tyre §1.1) + produces: + - 10-axis NPC generation per role slot + - Triangle instantiation with TrianglePurpose (§7) + - Entanglement marking (D-029: 20% entangled) + - Spawn location preferences (flavor type → NPC pattern weight) + - NPC schedule generation (DramaDensity-aware: opaque window timing — §1.2 A-3) + ↓ + +Stage 5: Transition Strip Generation + input: adjacent PreparedDistrict pairs + output: TransitionStrip per shared edge (Tyre §2.4) + produces: + - Palette blending (Araminta §1) + - Access point alignment + - Cultural bleed gradient (Miri §5) + ↓ + +OUTPUT: PreparedDistrict (DistrictSkeleton + BlockPlans + NpcRoster + SeedChain) + TransitionStrips (shared between adjacent PreparedDistricts) + + +═══════════════════════════════════════════════════════════════════ +PHASE 2: LOCAL AREA GEN (On-Demand, Per Chunk, ~100-500ms) +═══════════════════════════════════════════════════════════════════ + +Player enters loading radius of chunk + ↓ +Chunk Fill (per chunk, derived chunk_seed) + input: ChunkFillSpec (from BlockPlan), NpcRoster, SocietyProfile + output: ChunkData (64×64 tile array) + produces: + - Architecture/terrain tiles from template tag + - ALL tiles in chunk generated — including breach-only rooms (§3) + - TileBehindState applied to wall tiles (§3) + - Zone palette + era materials + - Furniture from form × function matrix + - NPC spawn points (flavor → NPC affinity weights applied) + - LOS anchors (urban: walls/pillars; non-urban: trees/terrain features) + - Edge contracts validated against loaded neighbors + - Per-floor tile generation for tall structures (z-band-aware) + ↓ +OUTPUT: ChunkData (cached in memory, saved to save file) + + +═══════════════════════════════════════════════════════════════════ +WORLD STATE LAYER (Runtime, Post-Generation) +═══════════════════════════════════════════════════════════════════ + +WorldStateDelta stream (managed by gameplay systems, not generator) + ├── StructuralDamage (explosions, combat, decay) + ├── WallBreached (player/NPC destructive action) + ├── DoorStateChanged (persistent door states) + ├── ObjectModified (loot, evidence, planted objects) + ├── TileTypeChanged (post-damage tile state) + ├── AccessTierChanged (faction events, lockdowns) + └── NpcRemoved (killed, arrested, fled) + +Applied to: ChunkData at render/gameplay-query time +Large events: soft re-generation via (original_seed XOR event_seed) — §5.3 + +Rendering: Generator ChunkData + ordered WorldStateDeltas = current visible state +``` + +--- + +## 10. Canonical DistrictSkeleton Fields + +Addressing D-R2-3 (Tyre and Gestalt adding fields independently). Here is the composite canonical field list for `DistrictSkeleton`. Tyre should author the final Rust struct; this is the field-level specification: + +**Core (Tyre Round 1):** +- `district_id: DistrictId` +- `seed: u64` +- `district_type: DistrictType` +- `context: DistrictContext` +- `blocks: [[BlockSkeleton; 4]; 4]` +- `social_sites: Vec<SocialSitePlacement>` +- `reservations: Vec<MultiBlockReservation>` (updated with vertical extent — §4) +- `access_points: Vec<AccessPoint>` +- `corridors: Vec<CorridorSpine>` +- `z_levels: u8` → renamed `z_level_count: u8` +- `zone_palette: Vec<ZoneDefinition>` + +**Tyre Round 2 additions:** +- `boundaries: DistrictBoundaries` +- `society_profile: SocietyProfileRef` +- `terrain: TerrainType` +- `complexity: ComplexityTier` + +**Gestalt Round 2 additions (revised):** +- `setting_geometry: SettingGeometry` ← RETAINED +- `guarantee_audit: GuaranteeAuditResult` ← RETAINED (now conditional-aware per §2) +- `significance_tier` ← **REMOVED** (retired, absorbed into complexity + network position) + +**Gestalt Round 3 additions:** +- `vertical_structure: VerticalStructure` (Flat/Medium/Tall/Skyscraper — §4) +- `breach_only_zones: Vec<ZoneId>` (at least 1 for Full-complexity — §3) + +**Modification to SocialSitePlacement:** +- `triangles: Vec<TriangleTemplate>` — each `TriangleTemplate` gains `purpose: Vec<TrianglePurpose>` (§7) + +--- + +## 11. Open Questions for Implementation + +The workshop has converged. Three architectural decisions should be formally recorded before implementation begins: + +**OQ-R3-A (Grid rotation):** Tyre needs to decide whether district-level rotation is feasible within D-094 constraints, or whether boundary-level interventions (zigzag transition strip + diagonal infrastructure) are the full mitigation. From gameplay perspective: the boundary intervention is the minimum; full rotation would be ideal but is not required for V0.1-V0.3. + +**OQ-R3-C (Wilderness informal zone):** For wilderness/maritime settings, the Informal Zone guarantee still applies (it's Tier 1 Universal), but its terrain expression is different. I've proposed `terrain_informal_zone` (cave, ravine, hidden cove, underdeck hold). Miri should confirm the cultural meaning. + +**OQ-R3-D (Vessel architecture):** Miri's `bounded_mobile` flag for vessels needs architectural resolution before maritime DLC template authoring begins. Tyre's assessment should drive this. + +**OQ-R3-E (Horizon as landmark):** Ozzie's requirement that water's edge is a reserved landmark. Araminta has the visual grammar; the question is whether the district skeleton generator needs an explicit `coastal_landmark_reservation` or whether the natural zone palette transition is sufficient. From guarantee perspective: the horizon should be a Tier 2 guarantee for any district with `TerrainType::Water` on one boundary — ensure it's generated, not filled. + +--- + +*Gestalt — Round 3 complete. The pipeline is converged. Five directives addressed. Two redundant concepts retired. Tyre has the canonical struct reconciliation; Miri has the wilderness informal zone; Araminta has the horizon as landmark. These are the three remaining loose threads before architectural specification can be signed off.* + +*Let me break down what this means for implementation sequencing: the delta layer (§5) can be stubbed trivially in V0.1; the breach-only zones (§3) are a chunk fill flag, not a pipeline change; the vertical scale (§4) only matters when a Full-complexity district has a tall structure reservation. None of these require V0.1 implementation. The pipeline is sound for V0.1 with stubs.* diff --git a/docs/workshops/generator-architecture/gestalt-round4.md b/docs/workshops/generator-architecture/gestalt-round4.md new file mode 100644 index 000000000..b6bf27de4 --- /dev/null +++ b/docs/workshops/generator-architecture/gestalt-round4.md @@ -0,0 +1,440 @@ +# Generator Architecture Workshop — Round 4: Gestalt + +**Role:** Systems Design / Gameplay Loop Requirements +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) +**Round:** 4 — Final Convergence + +--- + +## Framing + +Four tasks this round. Three open questions to close. One guarantee to amend. Twelve D-records to sign off. The lead has already settled the major architectural splits (WorldTier, entity-carried chunks, DramaDensity as runtime). My job is to close the remaining mechanical questions with concrete answers and lock the D-records. + +Let me crack my knuckles. + +--- + +## 1. OQ-R4-C: Assassination Difficulty Descriptor — Definitive Answer + +Miri proposed `assassination_difficulty: low/medium/high/extreme` derived from: +**observation density × information liquidity × aftermath engagement** + +The question: where does it live? DistrictSkeleton, SocietyProfile, or computed on demand? + +**Answer: Computed on demand. Never stored.** + +Here's why this is the only defensible answer mechanically: + +### The inputs are not all static + +`assassination_difficulty` is a function of three input streams, only two of which are stable: + +| Input stream | Source | Stability | +|---|---|---| +| Observation density | SocietyProfile (heritage root, institutional coverage) | Stable (generator output) | +| Information liquidity | SocietyProfile (heritage root, settlement density) | Stable (generator output) | +| Aftermath engagement | SocietyProfile (heritage root, faction presence) | Stable (generator output) | +| Current NPC distribution | Storyteller state (DramaDensity, activated triangles) | **Dynamic** | +| Spatial audit satisfaction | A-1 through A-4 guarantee flags | Stable (generator output) | +| Active guard state | Simulation tick (faction events, alert level) | **Dynamic** | + +The dynamic inputs mean a stored `assassination_difficulty` on any struct would be stale the moment the storyteller fires an event. A political crisis event spikes guard coverage; aftermath engagement goes from `medium` to `extreme`. A faction purge reduces community observation. The stored value would be wrong within a single session. + +### Storing it causes incorrect player expectations + +If the player sees an `assassination_difficulty` assessment that was baked at district generation, they're reading a stale number. The immersive sim promise is that the world responds. If the player INCREASED guard presence by burning down the safe house two districts over, the difficulty should reflect that. A stored value can't. + +### The correct integration point + +``` +assassination_difficulty = + fn( + society_profile: &SocietyProfile, // from SocietyProfile (stable) + spatial_audit: &SpatialGuarantees, // from DistrictSkeleton (stable) + active_state: &StorytellerState, // from runtime (dynamic) + ) -> DifficultyDescriptor +``` + +This function is called: +- At contract acceptance (player sees their pre-op assessment) +- During pre-op planning phase (player can see how conditions change by day-phase) +- NOT stored anywhere persistent + +### What the D-record should specify + +The D-record for `assassination_difficulty` should specify: +1. The formula/weighting for the three input components +2. The four output levels (`low/medium/high/extreme`) and their thresholds +3. The integration points where the computation is invoked +4. That it is **explicitly not a struct field on DistrictSkeleton or SocietyProfile** — it is a derived computation + +The reason to canonicalize the formula in a D-record: other systems (NPC routing, faction responses, storyteller module selection) may want to consume the same computation. Having it defined once prevents different systems from computing it differently. + +**Verdict: OQ-R4-C is resolved. No persistent storage. Computed on demand from SocietyProfile + spatial audit + runtime state.** + +--- + +## 2. OQ-R4-F: Soft Re-Generation — The Concrete Example + +Ozzie's principle: destruction must be *caused*, not *random*. The question: does `original_seed XOR event_seed` satisfy this, or do we need structured damage parameters? + +I'm going to show you the actual output of both approaches for the same event. Then we'll know. + +### The Scenario + +**District X:** `district_seed = 0xA3F8C21B_7E64D509` +- Heritage root: Iron (dense residential, workshop clusters) +- Block layout: 4×4 grid, SMALL complexity +- Block (2,3): Worker residential block, ~60 tiles + - Floors 1-3: apartment units, corridor, shared kitchen + - Sub-level (z=-1): utility tunnel, gas line infrastructure +- Event: gas line rupture at (tile 2,3,38) at sim tick 47,302 + +--- + +### Approach A: XOR Reseeding + +``` +event_seed = hash(EventType::GasExplosion, TilePosition(2,3,38), SimTick(47302)) + = 0x5B7E349A_1C82A7F3 + +reseeded = 0xA3F8C21B_7E64D509 XOR 0x5B7E349A_1C82A7F3 + = 0xF886F6816AE672FA +``` + +The chunk fill re-runs on block (2,3) with `reseeded`. What does this produce? + +| Tile position | Before | After (XOR reseed) | +|---|---|---| +| (2,3,1) — entry corridor | Corridor tile, N-S orientation | **Corridor tile, E-W orientation** | +| (2,3,4) — apartment 1A | Residential interior | **Workshop space** (RNG diverged at zone assignment) | +| (2,3,12) — shared kitchen | Kitchen fixture cluster | **Storage room** | +| (2,3,38) — explosion origin | Gas line junction (sub-level) | **Open floor tile** | +| (2,3,40) — adjacent unit | Apartment interior | **Wall** (block subdivision changed) | +| (2,3,55) — block corner | Exterior wall | Exterior wall (stable, geometric) | + +**The result:** The block has been *replaced*, not *damaged*. Tile (2,3,4) changed from a residential apartment to a workshop — not because the explosion destroyed residential use and workers moved in; the generator just made different decisions with the new seed. The zone assignment diverged at the first RNG call that governs zone type selection. + +The explosion origin tile (2,3,38) lost its gas line fixture — but so did tiles across the entire block, because the fixture placement logic runs from a different RNG stream now. There's no spatial logic to the changes. The modifications don't radiate from the explosion center. + +**Diagnosis:** XOR reseeding is a blender, not a bomb. It mixes the content uniformly rather than concentrating disruption at a source. The result looks *replaced* rather than *damaged*. This fails Ozzie's test — the destruction has no cause visible in the output. + +**XOR reseeding is appropriate only for era-scale discontinuities**, where the settlement genuinely rebuilt from scratch (decades passed, original structures gone, new generation built different). It is wrong for in-playthrough events. + +--- + +### Approach B: Structured Damage Parameters + +```rust +struct GasExplosionEvent { + origin: TilePosition, // (2, 3, 38) + blast_radius: u16, // 8 tiles primary, 14 tiles secondary + intensity: f32, // 0.85 (high pressure rupture) + propagation_dir: Option<Dir>, // None (omnidirectional rupture) + ignition: bool, // true (gas ignites) +} +``` + +Application: the generator output is **unchanged**. The chunk maintains `original_seed = 0xA3F8C21B_7E64D509`. The damage event is appended to the `ChunkMutations` overlay: + +```rust +ChunkMutations { + structural_changes: [ + // Primary blast zone (radius ≤ 8 tiles): damage proportional to distance + StructuralChange { tile: (2,3,38), change: TileType::Rubble { debris_density: 1.0 } }, + StructuralChange { tile: (2,3,37), change: TileType::Rubble { debris_density: 0.9 } }, + StructuralChange { tile: (2,3,39), change: WallState::Breached { gap_size: 3 } }, + StructuralChange { tile: (2,3,36), change: TileType::Rubble { debris_density: 0.7 } }, + StructuralChange { tile: (2,3,4), change: TileType::Rubble { debris_density: 0.4 } }, + // Floor above (if loaded): ceiling collapse + StructuralChange { tile: (2,3,38+floor), change: FloorState::PartialCollapse }, + // Secondary zone (radius 8–14): soot, scorch marks, broken fixtures + TileOverride { tile: (2,3,50), visual_state: VisualMod::Scorched }, + TileOverride { tile: (2,3,51), visual_state: VisualMod::SootLayer }, + // ... + ], + removed_objects: [gas_line_fixture_38, apartment_door_36, ...], + placed_objects: [ + PlacedObject { pos: (2,3,42), object_type: DebrisPile, seed: derived }, + PlacedObject { pos: (2,3,35), object_type: FireScorch, seed: derived }, + ], +} +``` + +**The result:** + +| Tile position | Before | After (structured overlay) | +|---|---|---| +| (2,3,1) — entry corridor | Corridor, N-S | **Corridor, N-S** (unchanged) | +| (2,3,4) — apartment 1A | Residential interior | **Residential interior, debris scattered** (within blast radius but low intensity at distance) | +| (2,3,12) — shared kitchen | Kitchen fixtures | **Kitchen fixtures, scorched** (secondary zone) | +| (2,3,38) — explosion origin | Gas line junction | **Rubble, debris_density 1.0** | +| (2,3,40) — adjacent unit | Apartment interior | **Rubble, debris_density 0.8** | +| (2,3,55) — block corner | Exterior wall | **Exterior wall, soot marks** (secondary zone) | + +The block is recognizably itself — a worker residential block that has been damaged. You can see the block's original structure through the destruction. The explosion origin is identifiable. The damage radiates outward. The adjacent block at (2,4) is untouched. + +**This is caused destruction.** The spatial logic is legible. + +--- + +### Decision: Regeneration Strategy Enum + +```rust +enum RegenerationStrategy { + /// For localized in-playthrough events: explosions, fires, structural collapse + /// Generator output unchanged; damage applied as ChunkMutations overlay + LocalOverlay(DamageParameters), + + /// For district-scale temporal changes: rebuilding after war, years of neglect + /// Modify seed slightly; re-run generator for significant structural changes + /// Appropriate when player returns to a district 10+ years later (between scenarios) + SoftReseed { seed_modifier: u64 }, + + /// For era-level discontinuities: orbital strike, catastrophic flood, decades of war + /// Appropriate between major time-skip scenarios, not within playthrough + FullReseed, +} +``` + +**Rule:** In-playthrough events are ALWAYS `LocalOverlay`. `SoftReseed` and `FullReseed` only apply during scenario setup (between playthroughs or at major time-skip boundaries). The generator never re-runs for events the player witnesses or causes. + +This resolves OQ-R4-F. The D-record should canonicalize these three strategies and explicitly prohibit XOR reseeding for in-playthrough events. + +**Verdict: OQ-R4-F is resolved. LocalOverlay for in-playthrough events. XOR/soft reseed only at scenario boundaries.** + +--- + +## 3. Rooftop Bar Clause — Amended Guarantee + +### The Original Guarantee (Round 3) + +> "Every tall structure (z_band_count ≥ 3) must have a roof zone classified `Insider` or `BreachOnly` accessible by non-obvious route." + +### The Problem + +This forces ALL rooftops to be secret or restricted. But the setting has: +- Commission-era arcologies with public observation galleries +- Commercial towers with rooftop restaurants +- Religious structures with sky gardens +- A transit hub's roof terrace where residents watch shuttle departures + +The guarantee as written would require the rooftop restaurant to be an unauthorized trespass destination. That's wrong. Some rooftops are meant to be a public destination — a reason to climb, not a secret discovered by climbing. + +What the guarantee was *trying* to protect: the discovery element. Rooftops should never be structurally irrelevant. They should always offer something — either a restricted secret, or a public destination with a hidden layer. + +### The Revised Guarantee — Vertical Discovery + +**For every tall structure (z_band_count ≥ 3), at least one of the following must be true:** + +**Option A — Restricted Rooftop (Discovery Through Access)** +The primary roof zone is classified `Insider` or `BreachOnly`, accessible by non-obvious route. The discovery is the access itself. + +**Option B — Public Rooftop with Hidden Layer (Discovery Within Destination)** +The primary roof zone is publicly accessible (Social Hub, Economic Node, or equivalent). A secondary zone within the same z-band is classified `Insider` or `BreachOnly`. This could be: +- A maintenance level behind an access panel +- A restricted transmitter array within the rooftop space +- A private penthouse cluster separated by `Semi-Private` partition +- A service stairwell to a sub-roof level + +**The inviolable rule across both options:** Every tall structure must have *something* at the top that is not fully accessible from below. The discovery layer is mandatory. The public/private split of the primary space is not. + +### Generator Implementation + +```rust +enum RooftopConfig { + Restricted { + zone_class: AccessTier, // must be Insider or BreachOnly + access_route: RouteObviousness, // must be NonObvious + }, + PublicWithHiddenLayer { + primary_zone: ZoneType, // Social Hub, Economic Node, etc. + secondary_restricted: ZoneSpec, // always present; Insider or BreachOnly + }, +} + +struct MultiBlockReservation { + // ... existing fields ... + rooftop: RooftopConfig, // replaces the old "roof zone guaranteed restricted" constraint +} +``` + +### The Guarantee Audit Change + +Old check: +> "Does this tall structure have a roof zone classified Insider or BreachOnly?" + +New check: +> "Does this tall structure have a `RooftopConfig::Restricted` or a `RooftopConfig::PublicWithHiddenLayer` with a non-empty `secondary_restricted`?" + +Both options satisfy the audit. The key: the generator must choose one at district generation time based on the building's zone palette and heritage root. Commission-institutional buildings: `PublicWithHiddenLayer` (observation gallery + restricted records floor). Iron-heritage trade towers: `Restricted` (the roof belongs to the guild leadership). Frost-heritage isolated structures: `Restricted` (the roof is where the heating systems live and no one else goes up). + +**Verdict: Rooftop guarantee amended. Rooftop bars are valid. The discovery layer remains mandatory.** + +--- + +## 4. D-Record Sign-Off — All 12 + +Going through each. I'm flagging amendments where the D-record needs additional language beyond what the Round 3 notes contain. + +| # | Item | Status | My Position | +|---|---|---|---| +| D-READY-1 | DistrictLayoutMode: Grid / Organic | **SIGNED OFF** | No amendments. Canonical. | +| D-READY-2 | Guarantee Tier System | **SIGNED OFF** | Amendment below. | +| D-READY-3 | TrianglePurpose Enum | **SIGNED OFF** | No amendments. | +| D-READY-4 | WallBackside / TileBehindState | **SIGNED OFF** | Amendment below. | +| D-READY-5 | Dynamic Modification via Overlay | **SIGNED OFF** | Amendment below (from OQ-R4-F). | +| D-READY-6 | ZonePalette Modifier System | **SIGNED OFF** | No amendments. | +| D-READY-7 | Horizon View Corridor | **SIGNED OFF** | No amendments. | +| D-READY-8 | Assassin Lens Spatial Guarantees | **SIGNED OFF** | Amendment below. | +| D-READY-9 | Heritage Grammar Overlay | **SIGNED OFF** | No amendments. | +| D-READY-10 | Non-Urban Informal Zone Typology | **SIGNED OFF** | No amendments. | +| D-READY-11 | Vertical Scale Architecture | **SIGNED OFF** | Amendment below (Rooftop Bar Clause). | +| D-READY-12 | Trauma Events as EraModification | **SIGNED OFF** | Amendment below (from OQ-R4-F integration). | + +--- + +### D-READY-2 Amendment: Guarantee Tier System + +The D-record should include explicit naming for the three tiers: + +- **Tier 1 — Universal Inhabited Guarantees** (all inhabited districts, any complexity) + - Social Hub, Informal Zone, Encounter Corridor +- **Tier 2 — Full-Complexity Guarantees** (Full-complexity only) + - Traffic Chokepoint, Institutional Space, Insider Space, Economic Node + - Horizon View Corridor (coastal Full-complexity) + - BreachOnly Zone (≥1 per Full-complexity) +- **Tier 3 — Conditional Parameter Guarantees** (depend on district parameter values) + - Elevated Vantage, Egress Multiplicity, Temporal Opacity Window (A-1/A-2/A-3) + - Non-Institutional Access Route (A-4 — applies to all Full-complexity) + - Economic Asymmetry Signal (when `economic_disparity` flag present) + - Power Gradient Visibility (when `faction_control` field is non-null) + +The audit runs all applicable checks. A Minimal farmstead gets 3 checks. A Full-complexity coastal urban hub gets up to 12. The D-record should specify which checks are mandatory vs. which are triggered by parameter flags. + +--- + +### D-READY-4 Amendment: Dual Classification System + +The D-record should clearly establish that `TileBehindState` and `WallBackside` serve complementary roles and **both** are canonical: + +| Enum | Scope | Purpose | +|---|---|---| +| `WallBackside` (Tyre) | Structural | What is physically behind this wall tile (for generation and LOS) | +| `TileBehindState` (Gestalt) | Gameplay | What kind of space this represents for gameplay systems | + +These are not duplicates. A wall with `WallBackside::ServiceVoid` has `TileBehindState::Interstitial`. A wall with `WallBackside::AdjacentSpace` has `TileBehindState::HiddenRoom` OR `TileBehindState::StructuralFill` depending on access tier configuration. The D-record should canonicalize both enums and document the mapping between them. + +--- + +### D-READY-5 Amendment: RegenerationStrategy Integration + +Add to the D-record: + +```rust +enum RegenerationStrategy { + LocalOverlay(DamageParameters), // in-playthrough events; generator output unchanged + SoftReseed { seed_modifier: u64 }, // scenario-boundary temporal changes only + FullReseed, // era-level discontinuities only +} +``` + +**Explicit constraint in the D-record:** In-playthrough events must use `LocalOverlay`. `SoftReseed` and `FullReseed` are scenario-setup tools, not event responses. The generator does not re-run for player-witnessed events. + +--- + +### D-READY-8 Amendment: A-1 through A-4 as Tier 3 Conditional + +The assassin spatial guarantees (A-1: Elevated Vantage, A-2: Egress Multiplicity, A-3: Temporal Opacity Window, A-4: Non-Institutional Route) should be positioned explicitly as **Tier 3 Conditional Guarantees**, not as an assassin-specific subsystem. + +The D-record language should be: + +> "A-1, A-2, and A-3 are conditional guarantees triggered when `complexity_tier == Full`. A-4 is a mandatory Full-complexity guarantee (all playstyles benefit from non-institutional routes). These are derived properties of the existing spatial configuration, validated by the guarantee audit. They are not spatial features tagged for the assassin — they are properties that any playstyle can discover and exploit." + +This framing prevents scope creep where assassin-specific content gets its own generation budget. The guarantees audit against existing spatial output; they don't add generation cost. + +--- + +### D-READY-11 Amendment: Rooftop Bar Clause + +The D-record should replace the original guarantee with the amended `RooftopConfig` model from Section 3 above. Specifically: + +> "Every tall structure (z_band_count ≥ 3) must specify a `RooftopConfig`. If `Restricted`, the roof zone must be `Insider` or `BreachOnly` with a non-obvious access route. If `PublicWithHiddenLayer`, the primary public zone must be accompanied by a secondary restricted zone within the same z-band. The discovery layer is mandatory in both configurations. Heritage root and building zone palette determine which configuration the generator assigns." + +--- + +### D-READY-12 Amendment: Trauma Event + RegenerationStrategy + +Trauma events trigger `LocalOverlay`, not reseeding. The D-record should explicitly state: + +> "`ModificationType::TraumaEvent` applies structural changes via `ChunkMutations::LocalOverlay`. The generator output (original_seed) is preserved. Cultural aftermath decays toward baseline at heritage-root-dependent rates, tracked in simulation state. Physical destruction and cultural aftermath are separate tracks — the wall being rubble is a `StructuralChange`; the community's altered NPC weight distribution is simulation state that decays." + +--- + +## 5. Lead Decisions — Acknowledged + +The following lead decisions are received and incorporated: + +**WorldTier wins over SignificanceTier** + +Acknowledged. `WorldTier` correctly describes what this parameter measures: the simulation fidelity budget allocated to this location. `SignificanceTier` implied narrative importance, which is wrong — a politically significant backwater still gets Minimal complexity if the generator didn't budget for it. The field is now `world_tier: WorldTier` on the DistrictSkeleton. + +**Entity-carried chunks are CORE architecture** + +Acknowledged. `MobileChunk` as entity-carried `ChunkData`. Vessels exist as persistent world entities — docked at port, visible from the dock, present on the world map. The exterior is a scrolling visual buffer in `InTransit` state. Miri's cultural grammar applies fully to both static and mobile chunk types. The arrival-deadline temporal pressure is core gameplay. + +**DramaDensity is runtime state, NOT on DistrictSkeleton** + +Acknowledged and confirmed from my own Round 3 position. The DistrictSkeleton carries the capacity ceiling. The storyteller carries the current value. The D-records should explicitly state this constraint. + +--- + +## Final Pipeline Statement — Locked + +Three-layer model, canonicalized: + +``` +GENERATOR STATE (immutable after Phase 1) +├── Phase 1: DistrictSkeleton +│ ├── world_tier: WorldTier (simulation fidelity budget) +│ ├── complexity_tier: ComplexityTier (content budget) +│ ├── layout_mode: DistrictLayoutMode (Grid | Organic) +│ ├── spatial guarantees: Tier 1/2/3 audit flags +│ ├── rooftop: RooftopConfig (Restricted | PublicWithHiddenLayer) +│ └── society_profile: SocietyProfile (heritage root, institutional coverage, etc.) +└── Phase 2: PreparedDistrict + ├── SocialSitePlacement (triangles with Vec<TrianglePurpose>) + ├── NpcManifest (seeded from society_profile) + ├── ZonePalette assignments (base + modifiers) + └── ChunkMutations pending (pre-queued from simulation events) + +SIMULATION STATE (runtime storyteller) +├── DramaDensity (per-district, storyteller-controlled) +├── ActivatedTriangles (subset of SocialSitePlacement) +├── assassination_difficulty (computed on demand from SocietyProfile + audit + runtime) +└── StorytellerModules (fired events, fragility triggers) + +DELTA LAYER (post-generation) +├── ChunkMutations applied (LocalOverlay for in-playthrough) +├── NpcRemoved / NpcStateChanged +├── AccessTierChanged (factions seal or open zones) +└── WorldStateDelta (composed from all active mutations) +``` + +These three layers compose at render time. The generator never re-runs. The pipeline is locked. + +--- + +## Open Questions Remaining + +**None.** + +- OQ-R4-A: Entity-carried chunks selected by lead. Resolved. +- OQ-R4-B: WorldTier selected by lead. Resolved. +- OQ-R4-C: Computed on demand. Resolved (Section 1). +- OQ-R4-D: Heritage grammar overlay — this is OQ-R4-D which is Araminta's domain (representation in chunk fill assets vs. modifier objects). I'm waiting on Araminta's response; it doesn't block D-record production since the *content* of the heritage grammar (Miri) and the *integration point* (modifier system, D-READY-6) are both locked. +- OQ-R4-E: One NPC, five lenses — Nigel's domain. Doesn't affect my output. +- OQ-R4-F: LocalOverlay for in-playthrough events. Resolved (Section 2). + +**Round 4 closes from my side. Twelve D-records ready. Three open questions resolved. Pipeline locked.** diff --git a/docs/workshops/generator-architecture/gestalt-round5.md b/docs/workshops/generator-architecture/gestalt-round5.md new file mode 100644 index 000000000..2180325b8 --- /dev/null +++ b/docs/workshops/generator-architecture/gestalt-round5.md @@ -0,0 +1,95 @@ +# Generator Architecture Workshop — Round 5: Gestalt (Final Review) + +**Role:** Systems Design / Gameplay Loop Requirements +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) +**Round:** 5 — Final Review + +--- + +## Review: Does `workshop-outcomes.md` Accurately Capture My Contributions? + +Short answer: **Yes, with two minor corrections.** + +--- + +## What Is Accurately Captured + +### Three-Layer Model + +The generator/simulation/delta separation is correctly captured and the pipeline diagram in the outcomes matches my Round 4 locked state. The immutability of generator output post-Phase 1, and the composition of layers at render time, are correct. + +### LocalOverlay Mandate + XOR Prohibition + +The RegenerationStrategy enum is correctly specified with all three tiers: +- `LocalOverlay` for in-playthrough events (mandatory) +- `SoftReseed` at scenario boundaries only +- `FullReseed` at era-level discontinuities only + +The explicit XOR prohibition is correctly recorded under both D-READY-5 and D-READY-14. The separate D-READY-14 records the prohibition as an architectural mandate, which is the right framing — it's bigger than just the overlay mechanics. + +### RooftopConfig: Restricted | PublicWithHiddenLayer + +The amended Rooftop Bar Clause is correctly captured. The key requirement — discovery layer mandatory in both configurations, heritage root drives assignment — is accurate. The guard rails around mandatory hidden layers are preserved. + +### D-READY-4: Dual Classification System + +The `WallBackside` / `TileBehindState` split is correctly framed as complementary, not duplicated. The mapping (ServiceVoid → Interstitial; AdjacentSpace → HiddenRoom or StructuralFill) is accurate. + +### D-READY-8: Assassin Lens as Derived Properties + +Correctly captured: A-1 through A-4 are derived properties of existing spatial configuration, not assassin-tagged features. "They add no generation cost; the audit validates existing output." That is the exact framing from my Round 4 and it is preserved. + +### Assassination Difficulty — Tension Preserved + +The outcomes document correctly notes the minor tension between my "computed entirely on demand" position and Miri's "stored cultural baseline" position, and correctly flags it as Q-NNN-f (open question for sprint work). The recommended synthesis — stored baseline in DerivedDistrictAnalysis + on-demand runtime computation for player-facing assessment — is a reasonable middle ground. I accept this as the forward direction. + +### Open Questions Attributed to Me + +Q-NNN-d (DramaDensity naming) and Q-NNN-f (assassination difficulty synthesis) are correctly attributed and framed. + +--- + +## Corrections + +### Correction 1: Guarantee Audit Count — Minor + +**What the outcomes say:** "A Full-complexity coastal urban hub gets up to 13." + +**What my Round 4 said:** "up to 12." + +The discrepancy is one check. Reviewing the Tier 2 and Tier 3 lists in D-READY-2: + +- Tier 2: Traffic Chokepoint, Institutional Space, Insider Space, Economic Node, Horizon View Corridor, BreachOnly Zone, Rooftop Discovery Zone = **7** +- Tier 3: A-1, A-2, A-3, A-4, Economic Asymmetry Signal, Power Gradient Visibility = **6** +- Tier 1: Social Hub, Informal Zone, Encounter Corridor = **3** (these always run) + +3 + 7 + 6 = 16 potential checks, but most Tier 3 checks are conditional. "Up to 13" vs "up to 12" depends on whether Rooftop Discovery Zone (Tier 2) was included in my count or not. Given that the Rooftop Bar Clause was an amendment I made in Round 4 after the original count was established, it appears the outcomes correctly adds it and reaches 13. **The outcomes document is correct; my Round 4 count of "up to 12" did not yet include the Rooftop Discovery Zone that I myself amended into Tier 2.** + +No change needed to the outcomes document — it has the right number. + +### Correction 2: Field Name — `complexity` vs `complexity_tier` + +**What the outcomes say (pipeline diagram):** `complexity: ComplexityTier` + +**What my Round 4 pipeline diagram said:** `complexity_tier: ComplexityTier` + +The outcomes document uses `complexity` as the field name in the Phase 1 struct. My Round 4 used `complexity_tier`. The outcomes document should use consistent naming — either `complexity` (shorter, idiomatic) or `complexity_tier` (explicit, parallel with `world_tier`). + +**My recommendation:** Use `complexity_tier` to parallel `world_tier`. Both fields identify a tier; both field names should follow the same convention. The outcomes diagram should be corrected to `complexity_tier: ComplexityTier` for consistency. + +**This is a minor point.** If the lead prefers `complexity`, that's also fine — it just needs to be consistent everywhere. + +--- + +## Items Added by Other Participants (No Objections) + +**D-READY-13 (MobileChunk)** and **D-READY-14 (DamageOverlay/RegenerationStrategy as separate D-record)** were not in my Round 4 twelve-item sign-off table because they were developed primarily by Tyre/Miri/Nigel and the 14-record count was assembled from the full team's output. I have no objections to either. D-READY-13 is correctly scoped (entity-carried, no Phase 1/Phase 2 split, departure schedules mandatory). D-READY-14 correctly separates the prohibition into its own record. + +--- + +## Summary + +The outcomes document accurately represents my Round 4 contributions. One self-correcting count discrepancy (12→13 checks, resolved by my own Rooftop Bar Clause amendment), one minor naming consistency question (`complexity` vs `complexity_tier`). No substantive misrepresentations. No positions attributed to me that I didn't hold. + +**Round 5 review complete from my side.** diff --git a/docs/workshops/generator-architecture/miri-round1.md b/docs/workshops/generator-architecture/miri-round1.md new file mode 100644 index 000000000..38cdf9065 --- /dev/null +++ b/docs/workshops/generator-architecture/miri-round1.md @@ -0,0 +1,436 @@ +# Generator Architecture Workshop — Round 1: Miri (Worldbuilder) + +**Topic:** Worldbuilding inputs for the generator pipeline +**Date:** 2026-02-27 +**Source decisions:** D-025, D-036, D-093, D-095, Q-032, Q-036, Q-039 +**Prior art:** Wiki Review Workshop Round 4 (Miri — ingredients menu, society profile) + +--- + +## Orientation: What worldbuilding supplies to the generator + +The generator does not produce setting — it *reproduces* setting from parameters. My role is to define what those parameters are and where in the pipeline they enter. This document organizes my prior work (wiki-review Round 4 ingredients menu, Krenn System brief) into a form the pipeline architecture can consume. + +Everything below connects to the confirmed existence of Q-032 (cultural ingredients menu, approved by lead but not yet formally specified). This workshop is the first place those parameters need to map onto a concrete pipeline. That mapping is what I'm providing here. + +--- + +## Question 1: How does the generator reproduce the cultural/economic variation of 300 worlds? + +The short answer: **through a society profile assembled from combinatorial ingredients, not through per-world hand-authoring**. + +The longer answer follows from what I established in Wiki Review Round 4. The Krenn System brief (Rounds 1-2 of that workshop) was a proof of concept — one complete output. Round 4 inverted the question: instead of writing a brief for each world, we design the *ingredient pantry* from which any brief can be generated. + +### The ingredient categories (Q-032 scope, prior work summary) + +I defined six ingredient categories in Wiki Review Round 4. Restating them here as formal generator inputs: + +**A. Heritage Roots** (1-3 selected, blend weights summing to 1.0) + +Ten roots, each a parameter bundle encoding phonetics + social dynamics + trust model: + +| Root | Phonetic Signature | Social Signature | Trust Model | +|---|---|---|---| +| **Frost** | Compact, consonant-clusters, hard stops | Reserved, privacy-first | Patience — time + shared labor | +| **Tide** | Flowing, vowel-rich, soft consonants | Expressive, group-oriented | Hospitality — sharing food/space | +| **Iron** | Heavy, rhythmic, gutturals | Communal solidarity, labor-proud | Collective — trust the group | +| **Spice** | Layered, precise stress, sibilants | Extended kinship, spiritual undertone | Kinship — blood/marriage networks | +| **Jade** | Precise, clean, varied-short | Hierarchical, honor-aware | Competence — skill earns respect | +| **Dust** | Rhythmic, open vowels, nasals | Communal decision, oral tradition | Witness — public demonstration | +| **Vine** | Warm, soft stops, rolled consonants | Class-conscious, family honor | Family — recognized kinship | +| **Salt** | Pragmatic, clipped, dental | Individualist, commercial | Transaction — fair dealing | +| **Stone** | Steady, balanced, laterals | Enduring, traditional, land-connected | Tenure — long presence | +| **Arc** | Sharp, fricatives, unusual combos | Intellectual, cosmopolitan | Argument — demonstrated reasoning | + +Blend example (confirmed Krenn): `{frost: 0.55, salt: 0.30, iron: 0.15}`. The blend weights produce naming phonetics, social dynamics, and trust parameters. The Krenn System regional brief from wiki-review Rounds 1-2 is the validated output of this blend. + +**B. Settlement Motivation** (one primary) + +Economic-extractive / economic-trade / economic-agricultural / ideological-political / ideological-academic / institutional-military / institutional-administrative / refugee / frontier-adventurist. + +Absence is valid: a world with no ideological motivation has no philosophical frame for its grey economy. People smuggle because they need to, not because they believe anything. That absence IS character. + +**C. Economic Function** (current activity, may differ from founding motivation) + +Extraction / logistics / manufacturing / agriculture / services / research / military-security / administrative / transit / mixed. + +**D. Economic Pressure** (1-2 selected — what makes extra income tempting) + +Tight-margin / debt-trap / status-competition / survival-gap / opportunity-disparity / prohibition-economy / generational-extraction. + +Krenn is `[tight-margin, prohibition-economy]`. That double pressure produces the specific moral texture: economically rational AND ideologically defensible. A different system with `[survival-gap, prohibition-economy]` produces the same contraband type but a more desperate, less principled grey economy. The player feels the difference in NPC motivation. + +**E. Drift Stage** (age modifier on heritage blending) + +Pioneer (0-50yr) / crystallizing (50-150yr) / mature (150-300yr) / ancient (300+yr). + +Drift affects how blended the roots are and how much novel cultural content has emerged from local conditions. At "ancient," the heritage roots are substrate — barely detectable. At "pioneer," the primary root dominates strongly. This is the generator's primary defense against franchise similarity. + +**F. Absence Parameters** (what's missing) + +Any ingredient category can be NULL: +- No heritage consciousness → functional naming, no substrate words, no food traditions +- No institutional authority → self-governing, Commission-absent, grey economy is the only economy +- No community bonds → transient population, no social fabric to investigate +- No ideological framework → people survive, they don't philosophize + +NULL values produce distinct societies. A transit hub with high turnover and no heritage consciousness plays completely differently from a mature logistics district. + +### How 300 worlds get variety + +The ingredient space is large but bounded: +- 10 roots × 3 blend positions (with weights) = many thousands of phonetic/social combinations +- 9 motivation types × NULL option = 10 states +- 10 economic functions = 10 states +- 7 pressure types × 2 picks × NULL option = combinatorial +- 4 drift stages + +Conservative estimate: even constraining the ingredient space heavily, the combination space comfortably exceeds 300 meaningfully distinct societies. The deeper question (for Nigel and Gestalt) is whether the GAMEPLAY variation matches the cultural variation — whether cultural differences translate to meaningfully different investigation experiences. My input: the society profile parameters feed directly into dialogue access thresholds, NPC pattern distributions, and trust-building timelines. Cultural variation produces mechanical variation. + +**The parameter that translates culture to gameplay:** `social.privacy_level` and `trust.building_rate` are the key levers. A Frost-dominant society (high privacy, slow trust) produces patient-observation investigations. A Tide-dominant society (low privacy, fast trust) produces social-network investigations. The same PC archetype plays completely differently across these cultures. + +--- + +## Question 2: What lore-level inputs drive the pipeline? + +Breaking this down by the specific categories the brief mentions: + +### Faction identity + +Faction presence enters the pipeline at **two stages**: + +**Stage 1 — System-level (above geography):** Before any geography is generated, the system has a political classification: +- Commission presence tier: comprehensive / standard / intermittent / absent +- Concord Assembly reach: represented / liaison-only / nominal / beyond-reach +- Syndic presence: dominant / significant / minor / absent +- Independent governance: none / district / station / system-wide + +These classify who controls the space. They set the baseline Meridian coverage tier (Commission presence = comprehensive coverage; absence = structural gaps). They also determine which faction templates are available for district-level social sites. + +**Stage 2 — District-level (at the skeleton):** Faction control at the district level determines: +- Which faction's social sites are instantiated (Commission inspection post vs. independent workers' hall) +- Authority NPC pattern distribution (more SYSTEM NPCs under strong faction control, more HANDLER NPCs under faction absence/power vacuum) +- What the grey economy structure looks like (ring-style horizontal cooperative vs. cartel-style vertical with handler hierarchy) + +Setting note — faction identity does NOT map to specific named factions at generation time. The system generates "dominant regulatory faction" and "local economic faction" from the political classification. Specific named factions (Commission, Concord Assembly, Talvik/Sova Logistics Consortium) are instantiated in hand-authored content only. + +### Economic function + +Economic function is both a society-level input (what the world does) and a district-level input (what this district specifically does within the world). Sova Transit District's economic function (logistics) differs from the station's overall mix. The district's function determines: + +- **Daily rhythm:** Shift-based / seasonal / project-based / client-based / continuous +- **Primary gathering trigger:** Shift-end / market-day / lecture-end / patrol-rotation +- **Primary social site type:** Bar (shift-end) / market (commercial) / lab commons (research) / mess hall (military) +- **Investigation vector:** Manifest discrepancies (logistics) / land records (agricultural) / research logs (academic) / chain-of-command gaps (military) +- **Grey economy structure:** What's being smuggled depends on what flows through legitimately. Logistics = cargo-embedded contraband. Research = stolen data or restricted compounds. Manufacturing = diverted materials. + +### Tech level + +Tech level is not a simple progression tier. It's a **three-axis profile**: + +**Axis 1: Meridian coverage density** (surveillance infrastructure) +- Comprehensive (Commission-grade): full public and institutional coverage +- Standard: common areas covered, private spaces standard-grade +- Degraded: structural gaps, older infrastructure, potentially exploited +- Minimal: maintenance corridors, pre-Meridian construction, no effective coverage + +This is already canonized for Sova (D-093 zone palette by coverage tier). The generator must assign coverage tiers per zone/district based on construction era + institutional investment + ring/grey-economy interference. + +**Axis 2: Neural lattice penetration** (how much of the population has lattice) + +Station Sova's working-class logistics district: broad lattice penetration, but regulated to basic-tier. This is what makes aftermarket lattice components valuable — broad desire, restricted supply, economic barriers to legitimate upgrade. + +At 300 worlds, lattice penetration is a society parameter that affects: +- What contraband type is in demand (high penetration + strict regulation = aftermarket components; low penetration + no regulation = basic access kits) +- PC perception modes available +- NPC information-sharing patterns (Meridian-mediated vs. physical word-of-mouth) + +**Axis 3: Infrastructure age / construction era** + +Already canonized in D-093 (Z-level model: z=0 Era 1 maintenance, z=1 operational, z=2 Era 3 gate cluster). Era tagging is the generator's architectural stratification tool. Different eras produce different: +- Structural materials (visual coherence input for Araminta) +- Meridian coverage gaps (older = less coverage) +- Social dynamics (workers in Era 1 maintenance have different relationship to the station than workers in Era 3 institutional spaces) + +### Population density + +Population density is **extrapolated from economic function and capacity**, not input directly. This matches the Cities Skylines model (population follows zoning/capacity). From a worldbuilding perspective: + +- Logistics district: 30-40 workers per shift × shift overlap = ~800 permanent residents on Sova (D-036 confirmed). High transient component (freight crews, visitors). +- Transit hub element: adds transient flux. The investigation texture changes when a significant portion of the population is temporary — transients have less community loyalty but also less community protection. + +For the generator, population density should be an OUTPUT of the capacity calculation, then fed back as an input to social site scale (how large a social site needs to be to serve this population). + +### Historical events + +Historical events are the generator's "damage to the initial output" pass. A society profile describes what the world was like at steady state. Historical events modify that: + +- **Founding crisis** (refugee wave, corporate collapse, war): pushes drift_stage forward for that event's effects while leaving broader culture at current drift +- **Economic disruption** (resource depletion, trade route change): shifts economic pressure parameters mid-system history +- **Institutional incursion** (Commission crackdown, Syndic restructuring): changes faction presence tier, modifies authority attitude parameter + +Sova's relevant historical events: +- Original Talvik Logistics founding (~180yr ago): founding motivation = economic-trade, sets initial parameters +- Syndic restructuring into Sova Logistics Consortium (~80yr ago): corporate disruption event, modest authority attitude shift +- Current ring activity: not a historical event in the generator sense — it's the Tier 1 drama module applied to an otherwise stable logistics district + +For the generator, historical events are a modifier pass AFTER society profile generation. They produce anomalies — places where the current state doesn't match the expected parameters because something happened. + +--- + +## Question 3: Station settings vs. planet-side cities vs. orbital installations + +**Recommendation: Same pipeline, different geography topology input.** + +The geography input is the first stage of the pipeline. What differs between setting types is the *geometry of what geography can be*, not the pipeline structure itself. + +### Stations + +Closed-envelope geography. Key characteristics: +- **Bounded and layered:** Z-levels are hard separations (D-093). No "outside." Access topology is vertical as much as horizontal. +- **Era-stratified:** Older construction is typically the foundation (z=0, maintenance, low coverage); newer construction is the top (z=2, institutional, high coverage). This is reversed from planet-side where old=historic center, new=suburbs. +- **Class is expressed spatially:** Upstation = institutional/administrative. Working level = operational. Below = maintenance/grey zone. Players can read social class from Z-level in a way that planet-side maps can't reproduce. +- **No natural geography:** Weather is HVAC. "Outdoors" is a viewport. Natural beauty is completely absent unless the station was designed as a habitat (which logistics stations are not). +- **Transit-culture possibility:** If the station is a hub (like Sova), a significant fraction of the population is transient. The grey economy can exploit this — unfamiliar faces don't get scrutinized. + +**Generator implication:** Station geography is a *zone-and-level grid*. The block generator produces zones (institutional, operational, maintenance, commercial, residential) arranged in vertical layers rather than geographic gradients. + +### Planet-side cities + +Open-envelope geography. Key characteristics: +- **Unbounded and spread:** Natural geography (terrain, water, weather) shapes districts. Investigation can include outdoor traversal. Districts are separated by natural boundaries (river district, hillside administrative quarter, dockyards). +- **Weather as gameplay element:** Already canonized for Velen (D-050 — fog degrades vision cones, storyteller times weather for dramatic effect). Planet-side investigations use weather as a genuine mechanical variable. +- **Era-stratified differently:** Historic center vs. expansion vs. suburbs. Old is typically the heart of the city. New construction is peripheral. Class is expressed through distance from center and quality of infrastructure. +- **Agriculture possible:** Rural surrounding territory. Food culture is stronger. Seasonal rhythms affect NPC schedules. +- **Gravity normal:** Station-born visitors notice 1.0g. Gravity is background reality for inhabitants. + +**Generator implication:** Planet-side geography is a *terrain-influenced zone spread*. The district generator places districts according to natural features rather than Z-levels. + +### Orbital installations (non-station) + +Specialized closed-envelope. Key characteristics: +- **Smaller and more homogeneous:** Research platforms, military outposts, mining operations. Population is typically 200-1000 rather than 12,000. Smaller population = fewer social sites, tighter community. +- **Single-purpose:** One economic function dominates. There's no "upstation commercial quarter" — everything serves the primary function. +- **Higher institutional control:** Smaller populations are more supervised. Meridian coverage is usually comprehensive. Grey economies exist but are harder to maintain — everyone knows everyone. +- **Mission-temporal:** Installations may have defined operational lifespans. Workers rotate. This pushes toward "pioneer" drift stage even for old installations (constant personnel rotation prevents cultural accumulation). + +**Generator implication:** Orbital installations use the same pipeline but with constraints: single economic function, small social site count, high coverage baseline, low drift stage. + +### The same pipeline handles all three + +The pipeline differences are: +1. **Geography topology generator** (first stage): station → zone-and-level grid; planet-side → terrain-influenced spread; orbital → single-zone constrained +2. **Heritage drift adjustments:** Station and orbital installations push toward slower drift (no natural anchors, more cosmopolitan mixing); planetary surfaces allow stronger regional drift (geographic isolation) +3. **Meridian baseline:** Station and institutional orbital → higher baseline; planet-side rural → lower baseline + +Everything downstream (amenities, zoning, block generation, chunk fill) runs the same logic. The geography input shapes what's available; the downstream stages fill it with culturally-appropriate content. + +--- + +## Question 4: What makes Sova's Transit District culturally distinct from a similar district on another station? + +This is the question the ingredients menu directly answers. Let me walk through it concretely. + +### A "similar district" defined + +A freight logistics district on a different station — same economic function (logistics), same setting type (station), same rough population scale (~800), similar construction era. + +### The Sova ingredients (specific) + +```yaml +heritage: + primary: frost # 0.55 — compact, reserved, patience-trust + secondary: salt # 0.30 — pragmatic, transactional, direct + tertiary: iron # 0.15 — labor solidarity, communal endurance + drift_stage: mature # 180 years +settlement_motivation: economic-trade # founded as a logistics contract, not a community +economic_function: logistics +economic_pressure: [tight-margin, prohibition-economy] # extra income tempting AND + # Commission lattice regulation = access-as-contraband +faction_presence: + commission: intermittent # present but not dominant; gate cluster + Upstation + concord: liaison-only # represented but distant + syndic: significant # Sova Logistics Consortium controls employment +philosophical_alignment: null # absent — pure pragmatism, no ideology +meridian_coverage: degraded # structural gaps + ring exploitation of existing gaps +``` + +### A different station's ingredients (example contrast) + +Call it Station Vareth — also a freight logistics hub, roughly same population: + +```yaml +heritage: + primary: dust # 0.60 — communal, oral tradition, public trust + secondary: vine # 0.40 — warm, class-aware, family-connected + drift_stage: crystallizing # 90 years — roots still distinct +settlement_motivation: economic-agricultural # farming colony that added a logistics hub +economic_function: logistics # same function +economic_pressure: [status-competition, generational-extraction] # DIFFERENT pressures +faction_presence: + commission: standard # more coverage than Sova + syndic: minor # smaller Syndic footprint, more independent operators +philosophical_alignment: labor-solidarity # workers have ideological framework +meridian_coverage: standard # better maintained, fewer gaps +``` + +### What the player experiences differently at Station Vareth + +**1. Investigation texture:** On Sova, silence is cultural (Frost/Salt = mind your business). On Vareth, silence is suspicious — a Dust/Vine culture talks, so NPC silence signals something specific. The detective reads the same cultural behavior (worker avoidance) as opposite signals. + +**2. Trust-building mechanism:** On Sova, trust requires patience — shared time and shared labor over months. On Vareth, trust requires public demonstration — you earn it by acting in ways the community can see and validate. The same investigation timeline produces different access levels. + +**3. Grey economy motivation:** On Sova, people smuggle because they're economically squeezed AND Commission regulation cuts them off from lattice upgrades they want. On Vareth, people participate in the grey economy because visible inequality (status-competition) creates social pressure to match higher earners, AND Syndic owners extract value from the community (generational-extraction). The contraband type may be similar (luxury goods, restricted equipment) but the moral texture is completely different. The detective's confrontation with ring participants lands differently. + +**4. Social site character:** Same template type (logistics district), different NPC population distribution. Vareth's higher philosophical alignment (labor-solidarity) increases ANCHOR and SYSTEM pattern counts — more community pillars, more institutionalized labor representation. Sova's null philosophical alignment produces more NOBODYs and CIVILIANs — the grey economy is moral shelter, not ideology. + +**5. Naming and ambient text:** Frost/Salt/Iron + mature drift produces Krenn-style compact consonant-heavy names (Kael, Voss, Drin). Dust/Vine + crystallizing drift produces different phonetics — possibly more flowing, more syllables, different stress patterns. The environment text (signs, graffiti, vendor names) sounds different. + +**6. Access topology** (the investigation architecture): Vareth's higher Meridian coverage and better-maintained infrastructure means fewer natural dead zones. The ring (or equivalent grey economy) on Vareth had to BUILD its dead zones rather than exploit existing gaps. This affects which locations are grey-economy-accessible and how the spatial investigation works. + +### The critical insight + +Sova's cultural distinctiveness is not decorative — it's mechanically load-bearing. The Frost/Salt/Iron + mature-drift + tight-margin + prohibition-economy combination produces **specific investigation difficulty, specific NPC behavior patterns, and specific contraband moral texture**. Change any two ingredients and you get a materially different play experience, even in an identically-structured logistics district. + +This is what the generator must preserve: not just that worlds look different, but that they PLAY differently because the cultural parameters drive mechanical parameters. + +--- + +## Question 5: Where do political/economic conditions enter the pipeline? + +**Short answer: Multiple stages, but primarily above geography and at the district skeleton.** + +Here is my proposed entry map, following the Cities Skylines pipeline structure from the brief: + +``` +[PRE-PIPELINE] System political classification + → Commission presence tier + → Concord Assembly reach + → Syndic presence scale + → Independent governance scope + → This sets the baseline institutional envelope for everything downstream + +[GEOGRAPHY] World type + natural constraints + → Station / planetary / orbital determines zone-topology geometry + → Economic function constrains what infrastructure is possible + +[INFRASTRUCTURE] Transport + utilities + → Economic function (logistics) → span gates, tram networks, freight lifts + → Faction presence → which infrastructure is Commission-maintained vs. independent + → Political conditions → maintenance allocation (the Sector 3 ventilation dispute is a + political-condition artifact: Industrial Sector queue vs. Transit District priority) + +[AMENITIES & SERVICES] Social site types available + → Faction presence → Commission office vs. workers' hall vs. independent clinic + → Economic pressure → what services the grey economy provides (aftermarket lattice here) + → Tech level (Meridian coverage) → what's surveil-able and what isn't + +[POPULATION] Extrapolated from capacity + → Economic function → shift-based or continuous population flow + → Faction control → how much transient vs. permanent population (Commission areas + have higher permanent fraction; transit areas have higher transient fraction) + +[ZONING] District type assignment + → Society profile → what zone types are culturally plausible + → Faction control → which zones are institutionally controlled vs. autonomous + → Grey economy → dead zones and maintenance corridors as informal zoning + +[BLOCK GENERATION] Chunk cluster arrangement + → Economic function → primary block type (freight staging, residential, commercial) + → Era stratification → block construction era affects coverage and condition + → Political conditions → bloc-level faction control (Commission inspection post in + the freight block, not the maintenance block) + +[CHUNK FILL — D-025 TEMPLATE INSTANTIATION] + → Society profile → NPC pattern distribution, NPC generation parameters + → Faction presence → which templates are eligible (Commission officer NPC in high- + presence zones; no Commission NPCs in grey-economy chunks) + → Economic pressure → moral frame of grey economy participation + → Access tier parameters → how social sites behave toward outsiders +``` + +### When D-025 templates get instantiated + +The workshop brief asks specifically where triangle templates are instantiated. My recommendation: **at the district skeleton stage, after zoning but before block generation**. + +The district skeleton generator: +1. Receives the society profile (from ingredients) +2. Receives the zoning output (district type, zone types) +3. Selects D-025-compatible social site templates appropriate to this society profile +4. Arranges them spatially (access topology) +5. Assigns NPC pattern/motivation slots per template +6. Establishes cross-template triangle connections (the one cross-template triangle per set, per D-024) + +The skeleton is then handed to block generation, which places the skeleton's abstract social sites into concrete spatial blocks. Chunk fill then populates those blocks tile-by-tile. + +### The Q-036 reconciliation + +Q-036 asks whether the district skeleton is the atomic generator output. My worldbuilding position: **Yes, and here is the key clarification that resolves the tension with D-025:** + +- D-025 defines social site templates as the atoms of *hand-authored* content +- The district skeleton is the *generator's composed output* — a configuration of social site slots +- The generator SELECTS AND ARRANGES D-025 templates; it doesn't replace them + +Analogy: D-025 templates are bricks. The district skeleton is the architectural plan that determines which bricks go where. The generator writes architectural plans from ingredients; human authors craft the bricks. The generator never touches the bricks themselves. + +The district skeleton as generator output contains: +- A list of social site slots (type: logistics-hub / bar / maintenance / residential) +- Spatial positions (approximate, for block generation to resolve) +- Access topology (which sites are gate-adjacent, which are maintenance-adjacent) +- NPC capacity and pattern distribution per site +- Triangle assignments (who's in conflict with whom across templates) +- Cultural modifier tags (which society profile produced this skeleton, for NPC generation) + +--- + +## Summary: What worldbuilding requires of the generator + +Drawing together the above into concrete requirements: + +**1. Society profile as first-class data structure** + +The generator must produce and consume a full society profile (Q-032) before any spatial generation begins. The profile drives NPC names, trust-building timelines, access tier thresholds, grey economy structure, and template selection. + +**2. Political classification above geography** + +Faction presence tier, Commission coverage, Syndic scale, and Concord Assembly reach must be resolved at system level before any district is generated. They constrain the entire downstream pipeline. + +**3. Era stratification as spatial dimension** + +Construction era is not just a visual tag. It determines Meridian coverage, access topology, and grey economy viability. The generator must tag zones and blocks by era and use those tags downstream. + +**4. Grey economy as a negative-space element** + +The grey economy occupies the spaces that official zoning doesn't account for. The generator must model what's NOT in the official map — which corridors are maintenance-only, which zones have dead spots, which blocks have unofficial access routes. This is not flavor; it's where the investigation happens. + +**5. Cultural distinctiveness must be mechanically expressed** + +Setting note — this is my strongest constraint: if cultural variation doesn't translate to mechanical variation (different investigation approach, different access timelines, different NPC behavior patterns), then 300 distinct cultural profiles produce only the illusion of variety. The pipeline must ensure that `privacy_level`, `trust.building_rate`, and `access_tier.*` parameters from the society profile actively modify NPC behavior systems. + +**6. D-025 templates as the atoms; district skeleton as the molecule** + +The generator arranges templates into skeletons. It does not alter the templates themselves. Hand-authored template quality is preserved; generator novelty comes from arrangement, not invention. + +--- + +## Open questions I'm flagging for the workshop + +**For Tyre:** +- Can the content pipeline consume the society profile YAML format (from wiki-review R4) as a serde-compatible schema? The parameter depth is significant. +- How does era-stratification map onto the chunk data structure? The Z-level model (D-093) is confirmed, but does the chunk system have era fields? + +**For Gestalt:** +- Does the NPC pattern × motivation distribution (wiki-review R4, Section 3) satisfy the gameplay guarantee requirements? Specifically: can a district skeleton guarantee "always a surveillance chokepoint" and "always a quiet zone" through pattern distribution alone? + +**For Nigel:** +- The cultural variation axes I've defined — `privacy_level`, `trust.building_rate`, heritage-root blend — are they sufficient replayability levers? Or do we need additional randomization in the society profile that produces surprise within a cultural type? + +**For Araminta:** +- The era-stratification and zone-type parameters should drive visual coherence. Are the era tags (Era 1/2/3) and zone types (logistics/maintenance/institutional/residential) sufficient input for chunk fill visual rules? + +--- + +**Status:** Round 1 complete. +**Author:** Miri +**Date:** 2026-02-27 +**Cross-reference:** Wiki Review Workshop Round 4 (Miri) — ingredients menu, society profile YAML spec, NPC pattern composition rules diff --git a/docs/workshops/generator-architecture/miri-round2.md b/docs/workshops/generator-architecture/miri-round2.md new file mode 100644 index 000000000..b9af5438b --- /dev/null +++ b/docs/workshops/generator-architecture/miri-round2.md @@ -0,0 +1,598 @@ +# Generator Architecture Workshop — Round 2: Miri (Worldbuilder) + +**Topic:** Worldbuilding for the broadened gameplay lens — multiple playstyles, non-urban terrain, insignificant places, edge bleed, cultural ingredients space size +**Date:** 2026-02-27 +**Source:** Round 1 (all participants), Qatux round notes, lead directive + +--- + +## Acknowledging the Lead Directive + +*This is NOT a detective game. It is a game about the inherent asymmetry of human awareness.* + +This reframe changes what the society profile must provide. In Round 1, I designed the society profile primarily through the lens of investigation. Every parameter pointed toward: how does this culture produce different investigation difficulty? What makes the grey economy more or less visible? + +That was too narrow. The society profile must be a **playstyle-agnostic information structure**. What the detective uses as evidence, the tycoon uses as a price advantage, the political actor uses as leverage, and the romantic pursuer uses as emotional vulnerability. The underlying architecture is the same — information asymmetry — but the information TYPE and what it UNLOCKS differs per playstyle. + +I'll work through this systematically. + +--- + +## Section 1: The Society Profile as a Playstyle-Agnostic Structure + +The society profile I defined in Round 1 already contains most of what multiple playstyles need. The gap is not the profile itself but **what information categories we're tracking** and **what actions they unlock**. + +The core claim: information asymmetry is the game's universal mechanic. What changes across playstyles is: +- **What information is valuable** (evidence, prices, affections, power leverage) +- **How it's accessed** (surveillance, market observation, social bonding, institutional positioning) +- **What it unlocks** (confrontation options, trade advantages, relationship phases, power leverage) + +The society profile governs HOW information flows (trust model, privacy level, access tiers). What needs to be added are the **information type taxonomies** — what exists to know per playstyle. + +--- + +## Section 2: Playstyle-Specific Information Vocabulary + +### 2.1 Investigation (existing — confirmed R1) + +**Information type:** Evidence of hidden activities +**Access mechanism:** Observation, physical traversal, social trust, institutional credentials +**Unlocks:** Confrontation options, exposure, arrest, exculpation + +Already designed. Society profile provides: `privacy_level`, `trust.building_rate`, `access_tier.*`, `grey_economy.*`. + +### 2.2 Tycoon (economic gameplay) + +**Information type:** Economic intelligence — prices, supply chains, trade routes, competitor knowledge +**Access mechanism:** Market observation, supplier relationships, contraband networks, faction briefings +**Unlocks:** Trade advantages, supply route control, economic leverage, faction debt + +The society profile parameters that drive tycoon gameplay are already partially present, but economic information needs its own vocabulary: + +```yaml +economic_information: + trade_flows: + surplus: [grain, recycled-metal, processed-protein] # what this world produces in excess + deficit: [lattice-components, pharmaceutical-grade, rare-fabrication-stock] # what it imports + choke_points: [span-gate-customs, logistics-hub-manifest-processing] # where trade is regulated + + price_differential_drivers: + - factor: faction_control # Commission control increases lattice component prices + - factor: supply_disruption_risk # isolated world = vulnerability premium on essentials + - factor: seasonal_demand # agricultural worlds have harvest-cycle price swings + + economic_actors: + dominant: syndic-consortium # sets baseline prices, controls infrastructure + independent: [owner-operators, ring-adjacent-traders] + absent: guilds # no formal guild structure in this region + + information_barriers: + # Who knows what before whom — the tycoon's asymmetric advantage + syndic_knows: [upcoming_supply_disruptions, contract_prices, preferred_customs_routes] + ring_knows: [actual_manifest_discrepancies, informal_price_tolerance, which_officers_turn_blind] + worker_knows: [shift_patterns, cargo_composition, unofficial_storage_locations] + outsider_knows: [official_listed_prices, public_trade_statistics, nothing_useful] +``` + +**What this does for gameplay:** A player in tycoon mode is trying to acquire the SYNDIC KNOWS tier. The ring's knowledge layer is a shortcut — but using it has risk. The outsider layer is useless. The game is about climbing the information ladder before competitors do. + +The cultural heritage profile modifies tycoon gameplay directly: +- **Salt-heavy societies**: transactional, information trades happen quickly and at fair rates. "Tell me what the Syndic pays for grain and I'll tell you who the next customs officer rotation is." +- **Frost-heavy societies**: information doesn't trade. You earn it through presence. The tycoon must invest time, not favors. +- **Iron-heavy societies**: economic information is community property. Hoarding it for personal advantage is a cultural violation. But sharing it within the labor community is expected — which gives organized workers better tycoon information than independent operators. + +### 2.3 Dating Sim (relationship gameplay) + +**Information type:** Social and personal knowledge — what someone wants, what they fear, who they're connected to, what their history is +**Access mechanism:** Shared experiences, trust-building, third-party gossip, observing behavior in different contexts +**Unlocks:** Relationship phases (warmth → intimacy → vulnerability → declaration), access to private spaces, rival network neutralization + +The society profile already handles the TRUST MECHANISM. What's missing is the **social venue diversity** and **relationship formation norms**: + +```yaml +social_venues: + # Dating sim needs more social site types than the investigation-centric design assumed + primary: + - type: communal_meal_space # shared eating, low-stakes interaction, natural conversation + - type: recreational_gathering # games, sports, performance — see the person relaxed + - type: crisis_support_space # medical, emotional — high-vulnerability, high-trust + - type: creative_work_space # collaborative creation, reveals character under pressure + - type: private_domestic # invited into someone's home — meaningful social threshold + +relationship_formation_norms: + # Heritage-root-dependent — this is one of the strongest cultural variables + frost: | + Relationships form through proximity and shared endurance, not explicit signals. + Expressing affection directly is uncomfortable and slightly aggressive. + The romantic signal is: inviting someone to a shared task. + "I thought you might want to help with the cargo rotation" = "I want to spend time with you." + This plays very differently for a tycoon or detective who misreads it as a work request. + + tide: | + Relationships form publicly, through shared food, shared celebration, introductions + to family. The romantic signal is inclusion in social gatherings. Introducing someone + to your family is serious. Cooking for someone is an explicit statement. The barrier + is managing group approval — everyone's opinion matters. + + spice: | + Relationships form through family networks. Third-party introduction is required. + Direct pursuit is presumptuous or inappropriate. The game is gaining approval + from the network before approaching the person directly. This creates an + investigation-like social puzzle: map the network, identify the influencer, + build the right relationships in the right order. + + salt: | + Relationships are transactional at initiation: "this benefits both of us." + That sounds cold but isn't — Salt cultures build real intimacy, they just + frame it practically. "I want to spend time with you because you're useful + to me" evolves into "I want to spend time with you because you're mine." + The evolution is the dating sim arc. +``` + +**Rival networks as triangle structures:** The dating sim's rival relationship is structurally identical to the investigation triangle — three people with conflicting interests. The generator's D-024 triangle model works for romantic competition: NPC A wants X, NPC B wants X, player wants X, each has different leverage. The CONTENT differs (romantic relationship vs. criminal conspiracy) but the mechanical structure is the same. + +This means the generator's triangle instantiation logic handles dating sim mechanics without modification. What changes is the **content tags** on the triangle nodes: `motivation: romantic-rival` vs. `motivation: operator`. The NPC 10-axis model already contains `Want` (relationship goal), `Secret/vulnerability` (what they're hiding), and `Tolerance threshold` (what they'll accept) — these are exactly the dating sim mechanics. + +**Social venue diversity as a generator requirement:** The investigation-centric design produced one primary social site type (bar — shift-end social aggregation). Dating sim gameplay requires more types: +- **Communal meal space** (low-stakes, natural conversation — distinct from the bar's crisis-adjacent social drinking) +- **Recreational activity venue** (sports, games, performance — see the person under relaxed conditions) +- **Domestic invitation threshold** (being invited home is a relationship milestone, requires a distinct spatial primitive) + +These social site types are different TEMPLATES in the D-025 library, not different pipeline stages. The generator needs a richer template pool selection at the amenities stage that includes non-bar social venues. Template pack DLC is the right model here — the base game templates cover the investigation/tycoon cases; a social expansion pack adds the dating sim template library. + +### 2.4 Political Drama (faction gameplay) + +**Information type:** Power intelligence — who controls what, who wants what, what compromises exist, which positions are vulnerable +**Access mechanism:** Institutional positioning, network cultivation, leverage acquisition, surveillance of faction actors +**Unlocks:** Alliance formation, faction control shifts, position seizure, scandal detonation, reform + +The society profile's faction presence tier gives the LANDSCAPE but not the TEXTURE. Political drama needs the internal dynamics of each faction: + +```yaml +political_structure: + power_structure_type: oligarchic # few actors with clear but contested hierarchy + # alternatives: democratic, feudal, revolutionary, absent + + contested_positions: + - position: district_administrator # currently weakly held; incumbent 2 years, insecure + competitors: [commission_regional, syndic_consortium] + leverage_held: [infrastructure_maintenance_authority, hiring_records] + + faction_relationships: + # Not just presence but HOW factions relate + commission_to_syndic: pragmatic_alliance # overlapping interests, no deep trust + commission_to_independent: surveillance # active suspicion, soft containment + syndic_to_workers: extractive_dependency # workers need the jobs; Syndic knows it + + leverage_map: + # What each faction needs from others — the political game's resource + commission_needs: [local_cooperation, manifest_accuracy, worker_testimony] + syndic_needs: [labor_stability, customs_efficiency, Commission_indifference] + workers_need: [fair_wages, lattice_access, protection_from_Commission] + ring_needs: [blind_spots, trusted_couriers, storage_access] + + destabilizing_information: + # Secrets that, if revealed, shift power + - secret: "District administrator is on the Syndic's informal payroll" + if_revealed_to: commission + effect: position_vacancy_plus_investigation + - secret: "Commission inspector has been running ring-adjacent favors for 3 years" + if_revealed_to: syndic_manager + effect: informal_coercion_leverage +``` + +**What cultural heritage does to political drama:** +- **Frost societies**: political conflict is cold and indirect. Faction warfare is bureaucratic, institutional, conducted through records and procedures. A Frost-dominated political drama is about paper trails and procedural capture, not public confrontation. +- **Iron societies**: political conflict is collective and labor-organized. Factions map to economic class. Political drama is about strikes, solidarity, collective action. The unit of power is the group, not the individual. +- **Arc societies**: political conflict is intellectual and reputational. The weapons are arguments, papers, and public debates. The person who can DEMONSTRATE they're right gains power. + +**The political drama and investigation crossover:** Political drama is investigation with a different goal. Investigation finds truth and decides what to do with it. Political drama is finding leverage and deciding how to deploy it. The knowledge graph (D-041) is the right architecture for both — the difference is what the player chooses to DO with `KnowsDetails`-tier information. The generator doesn't need to produce different spaces for political drama; it produces spaces where power is legible, and the player decides whether to expose or exploit what they find. + +### 2.5 The Universal Layer Beneath All Playstyles + +What I've worked through above reveals a unified structure: + +**Every playstyle is a different reading of the same information landscape.** + +| Playstyle | Reads the landscape as | Primary information type | Uses knowledge to | +|---|---|---|---| +| Investigation | A crime scene | Evidence of hidden activities | Expose/confront/arrest | +| Tycoon | A market | Economic intelligence | Profit/control/leverage | +| Dating sim | A social web | Personal knowledge/vulnerability | Form bonds/navigate rivals | +| Political drama | A power structure | Leverage points/destabilizing secrets | Shift/seize/reform power | +| Daily life (substrate) | A home | Social texture, belonging | Exist, build attachments | + +The generator produces ONE information landscape. What varies is which information the player's archetype seeks and what they do with it. This means: +- The society profile doesn't need playstyle-specific fields — it needs a RICHER information taxonomy that all playstyles can draw from +- The template library (D-025) needs richer social site variety — not just investigation-optimal spaces +- The faction presence model needs to expose internal dynamics, not just presence tiers + +--- + +## Section 3: Non-Urban Terrain Types and the Ingredients Menu + +The lead directive identifies: farmland, wilderness, secluded towns, ocean, boats, ski resorts, surf beaches. These are not population hubs. They need the generator, but not the same generator. + +My framework: **same ingredients menu, different terrain grammar, different social site library**. + +### 3.1 Agricultural / Rural Settings + +**Society profile parameters (typical):** +```yaml +heritage: + dominant_root: stone # land-connected, traditional, tenure-trust + secondary: tide # or vine — warm community, family bonds + drift_stage: ancient # agricultural settlements are often the oldest +settlement_motivation: economic-agricultural +economic_function: agriculture +economic_pressure: [generational-extraction, tight-margin] # landlord-tenant or margin squeeze +philosophical_alignment: land-stewardship # or null, or religious-traditional +faction_presence: + commission: nominal # present in theory, rarely acts + syndic: absent_or_minor # or a land-holding corporation (different from logistics Syndic) + local_governance: strong # elder councils, family heads, seasonal assemblies +``` + +**Terrain grammar — different from station/city:** +- No Z-levels. The map is ground-level with elevation variation (hills, valleys). +- Blocks are farmstead clusters, not building blocks. A "block" might be one farm with outbuildings. +- Infrastructure is roads/paths and water systems, not utility corridors. +- Social sites are dispersed: farmstead (domestic/work), market town (periodic social aggregation), local tavern/meeting hall (permanent small social site), fields/common land (semi-public work space). + +**Key generator difference:** Population is DISPERSED, not concentrated. The 4-8 NPC cluster radius of D-025 is too tight for agricultural settings. A farmstead's "social cluster" might be 3 people across 80 tiles — the farmer, their partner, their hired hand. The social site template library needs expanded radius limits for low-density settings. + +**The information landscape in agricultural settings:** +- Tycoon: land rights, crop prices, water allocation, trade route access to the nearest hub +- Investigation: boundary disputes, inheritance conflicts, who the landlord's agent actually reports to +- Dating sim: family approval (Spice/Stone/Vine heritage roots = family network gatekeeping) +- Political drama: who controls the local assembly, who the landlord's representative is, what the seasonal laborers want + +**Distinctive feature:** Agricultural settings have SEASONS. The game's time system (D-031 day phases) needs a longer-period layer — annual cycles — to fully represent agricultural social dynamics. Harvest festival = the major social aggregation event. Off-season = the grey economy's opportunity window (workers have time and reduced supervision). This is a significant generator parameter that urban settings don't need. + +### 3.2 Wilderness / Uninhabited Terrain + +Wilderness is not a settlement — it's a **terrain type that contains no permanent social sites**. + +**Generator grammar:** +- No society profile (no society) +- No social site templates +- Zone types: forest, mountain, water, open terrain, hazard zones +- Structures are: temporary camps, resource extraction points, abandoned installations, natural cover +- "Population" is: traversal NPCs (hunters, scouts, lost travelers), not residents + +**What wilderness provides the generator:** +- **Physical drama**: terrain hazards, navigation challenges, weather effects, cover and concealment +- **Resource nodes**: what can be extracted here — feeding the tycoon pipeline +- **Traversal topology**: connecting hub settlements, providing routes that avoid institutional oversight +- **Historical markers**: ruins of earlier settlements, abandoned infrastructure, graves — Ozzie's "history encoded in space" without a living community + +**Why wilderness matters for multiple playstyles:** +- Investigation: meeting contacts away from Meridian coverage; traversal to reach isolated evidence +- Tycoon: resource claims, extraction rights, trade route control +- Dating sim: the romantic retreat — being somewhere isolated creates intimacy intensity (and vulnerability) +- Political drama: the wilderness is where power vacuums are most complete; what fills them is the political story + +**Generator rule for wilderness:** The absence of a society profile is itself a data point. When the generator produces wilderness chunks, the political condition is "unclaimed" — and unclaimed territory is always contested, because it has no enforcement. Someone is always trying to stake a claim. This is the wilderness's faction dynamic. + +### 3.3 Secluded Towns / Small Settlements + +**The "insignificant place" problem is actually the secluded town problem.** A small settlement (population 50-300) is a full society but very localized. Let me address both together in Section 4. Here, I'll note what the generator needs for small-scale spatial grammar: + +- District = the entire settlement. No sub-districts. +- Blocks are individual buildings. +- Social sites are the same types but much smaller. +- The single bar (or tavern, or meeting hall) IS the entire public social life. + +**Small settlement society profiles:** +- High drift novelty (isolated = less cosmopolitan blending, more local invention) +- Strong insider/outsider dynamics (everyone knows everyone; a stranger is a social event) +- Low faction presence (either completely abandoned by institutions or ruled by a single institution with no competitors) +- High information concentration (one person can know everything about a small settlement within a week) + +This last point is a significant generator constraint: **in small settlements, the information asymmetry structure is INVERTED**. In a city, the player struggles to learn what's hidden because information is siloed. In a small settlement, the player potentially learns everything fast — but there's less to learn, and the NPCs know the player knows. The grey economy in a small settlement is more personal, more precarious, and more morally loaded. + +### 3.4 Maritime / Ocean Settings + +**Physical grammar:** +- Water tiles as traversal terrain (not walkable by default — requires vessel or swimming) +- Vessels as mobile social sites +- Ports as node concentrations +- Tidal variation (game-time-driven environmental change) + +**Society profile — port town:** +```yaml +heritage: + primary: salt # pragmatic, transactional — all ports are trading nodes + secondary: tide # flowing, community — maritime communities are tight-knit + tertiary: iron # solidarity — maritime labor culture is historically strong +settlement_motivation: economic-trade +economic_function: transit # the port exists because of what passes through +economic_pressure: [tight-margin, opportunity-disparity] # maritime labor is hard; the cargo wealth flows through, not to +``` + +**Vessels as special social sites:** + +Boats are bounded, mobile, intimate — and you cannot leave. This is one of the most extreme information asymmetry environments in the game: +- **No exit**: you cannot walk away from a conversation. Walk-away consequences (D-064) become literal — "walking away" means going below deck, not leaving. +- **Total observation**: everyone on the vessel knows everyone's movements. There are no blind spots on a small boat. +- **Time-pressured**: the voyage ends. What happens on the boat is either resolved before arrival or explodes at the dock. + +Vessels require a special social site template tag: `bounded_mobile`. This tag modifies: +- Trust-building rate: FASTER (forced proximity accelerates relationship formation — for better or worse) +- Privacy level: MUCH LOWER (physical impossibility of privacy on a small vessel) +- Access topology: all zones accessible to all residents (no insider/authority separation without physical space) + +**Ocean as wilderness:** Open ocean is wilderness with water terrain. The generator grammar is identical to land wilderness but with different traversal rules and different resource nodes (fishing grounds, salvage sites, submerged infrastructure). + +### 3.5 Tourist Economy Settings (Ski Resorts, Surf Beaches) + +These are structurally distinctive because they have **two simultaneous population profiles**: the service worker layer and the tourist/visitor layer. + +**Dual society profile:** +```yaml +resident_profile: + heritage: [whatever the local roots are] + economic_pressure: [tight-margin, status-competition] # service workers watch wealth flow through + economic_function: services + trust_building_rate: LOW_FOR_TOURISTS # "you're not one of us; you're passing through" + +visitor_profile: + heritage: [varies — wherever they come from] + economic_pressure: [null] # wealthy tourists have no economic pressure + economic_function: leisure + trust_building_rate: HIGH_FOR_LOCALS # "I'm here to relax, you're interesting, let's talk" + # Visitor trust dynamic INVERTS: tourists are easy to befriend + # but the friendship has a countdown (departure date) +``` + +**The generator implication:** Tourist economy settings need two NPC pools with different social behaviors. Service worker NPCs behave like Iron/Salt/Frost cultural types in the workforce (reserved, labor-solidarity). Tourist NPCs behave like visitors — OPEN, friendly at surface, but with no investment in the place and no loyalty to its community. + +The class contrast is explicit and spatial: +- The beach/slope is public territory: tourists and workers briefly co-present +- The worker housing and break rooms are insider territory: tourists excluded +- The luxury accommodation is restricted territory: workers enter only in service capacity + +This three-zone access structure maps cleanly onto Gestalt's access tier model (public/semi-public/private/restricted). The tourist economy setting is a natural generator case for teaching players about access tier systems — the gradient is visible and experiential. + +**Why this matters for multiple playstyles:** +- Tycoon: the money flows between visitor and resident. Who controls the access points controls the money. +- Investigation: the impermanence of tourist population makes witness tracking harder. "She was here last week" is meaningless if the witness left on Sunday. +- Dating sim: the vacation romance — a relationship with a departure date. Information asymmetry in its most poignant form. +- Political drama: the resort's owner/operator versus the workers versus the tourists versus the environmental/planning authority. Classic conflicting interests. + +--- + +## Section 4: Insignificant Places — The Worldbuilding of Unremarkable + +*Not every world is center-stage. Backwaters, in-betweens, unremarkable stops. What makes a place insignificant in worldbuilding terms? How does the society profile handle "nothing special happens here"?* + +### 4.1 What "Insignificance" Actually Is + +Insignificance is not a property of the society profile. It's a **relation** — a place is insignificant RELATIVE to the wider network. Sova is insignificant relative to a Core World hub. Sova is enormously significant to Sector 3 residents whose entire lives are bounded by the station. + +Scale of significance: +- **Network-significant**: Located at a trade/gate chokepoint; Commission attention; Syndic investment; people come here for reasons +- **Regionally significant**: Important within a cluster of nearby worlds; known to adjacent populations; occasionally in regional news +- **Locally significant**: The center of its own community's world; matters deeply to the people there; invisible to outsiders +- **Marginally located**: Transit stop only; no one lives here by choice; minimal community + +What changes between levels: **Faction pressure**, **economic investment**, and **external attention**. + +### 4.2 The "Insignificant" Society Profile + +A backwater settlement: +```yaml +network_position: marginal_located # NOT strategically important +faction_presence: + commission: absent # not worth the budget + syndic: absent # nothing to extract at scale + local_governance: informal # self-governing by default, not by charter +economic_pressure: [null] # no one is squeezing — there's nothing to squeeze +economic_function: subsistence # or tourism, if lucky +strategic_value: minimal +``` + +**What this produces culturally:** + +High `drift_novelty` — left alone, the community has developed genuine local quirks that more "significant" places have smoothed out in favor of cosmopolitan legibility. The insignificant place is often the most culturally DISTINCTIVE. + +High `insider_trust_threshold` — strangers rarely come. When one does, everyone notices. The player is an event. + +Low `information_density` — less is happening. But what IS happening is more visible. There are fewer layers of institutional obfuscation. The grey economy, if present, is one person in one back room, not a logistics ring spanning 200 workers. + +**The paradox of the insignificant place:** For gameplay, insignificant places are often MORE dramatically interesting than significant ones. The conspiracy in an insignificant place is: +- More personal (it's two or three people, not a network) +- More visible (the community is small; secrets can't stay hidden forever) +- More morally loaded (the stakes are local — what you do here affects everyone who lives here) +- More unique (not the same plot as the big-hub adventure) + +The generator should not treat insignificance as "less content to generate." It should treat it as a different content TYPE: intimate, local, high-stakes-for-small-scale. + +### 4.3 The Significant and the Unremarkable — Contrast Design + +For the 300-world model to avoid Second Station Syndrome (Ozzie's Sin #1), significant and insignificant worlds must feel categorically different, not just scale-adjusted. + +Significant hub: +- Multiple social sites +- Faction pressure visible in architecture +- Strangers are unremarkable +- Player can be anonymous + +Insignificant backwater: +- One social site (the gathering place) serves all functions +- No faction infrastructure +- Player is immediately noticed and remembered +- Player cannot be anonymous — everyone learns their name within hours + +**Generator constraint from this:** The skeleton must parameterize `anonymity_baseline` — how visible is a new arrival? On Sova Transit District, a new face in the bar is unremarkable; hundreds pass through. In a village of 60 people, a new face is the news of the week. The player's information management challenge INVERTS: not "discover what's hidden" but "manage that you can't hide anything." + +--- + +## Section 5: Edge Bleed — Cultural Zones Across Administrative Boundaries + +*Districts are not islands. Cultural zones bleed across administrative boundaries.* + +### 5.1 What Edge Bleed Is + +An administrative boundary is a line on a map. Cultural reality does not respect it. + +- The worker housing district bleeds into the freight district they walk through every day +- The Commission-controlled gate cluster's institutional culture bleeds into the adjacent terminal +- The market district's merchant culture bleeds into the first two blocks of the residential district +- The old maintenance corridor subculture bleeds into any building with basement access + +**Types of bleed:** + +| Type | Direction | Mechanism | +|---|---|---| +| **Economic bleed** | From economically active to passive zones | Vendors set up just past the zone boundary; commercial behavior follows foot traffic | +| **Faction bleed** | From high-control to low-control zones | Informants live in residential; Commission authority diffuses as social norm beyond formal boundary | +| **Cultural bleed** | Bidirectional, slow | Heritage practices, language, naming conventions spread gradually into adjacent communities | +| **Physical bleed** | From buildings/infrastructure | A building straddling a boundary creates ambiguous jurisdiction | +| **Information bleed** | Bidirectional, fast | Gossip, news, rumors don't respect borders; the bar on the edge of two districts is the information exchange | + +### 5.2 How the Generator Models Edge Bleed + +Round 1's society profile describes a district as having one society profile. But real cultural geography is gradients, not flat fills. + +**Proposed model: bleed gradient with decay distance** + +At the center of a district, the society profile applies at 100% intensity. At the boundary, it's blended with adjacent districts. The blend distance is: +- Short (2-4 blocks): sharp cultural boundary — different language, different customs, minimal mixing. This happens when the communities have high cultural distance AND the boundary is physically marked. +- Medium (5-8 blocks): gradual transition. NPCs near the boundary speak with slight mixing; social norms are flexible; spaces serve both cultures. This is the normal case for adjacent districts with moderate cultural distance. +- Long (9-16 blocks): extended transition — one culture is subordinate to the other, or both are very similar. This happens when two districts share heritage roots or when one has been culturally dominant for long enough to colonize the adjacent space. + +**Cultural distance function:** + +Two society profiles are CLOSE if they share: heritage roots, economic function, or similar faction presence tiers. +Two society profiles are FAR if they differ on: heritage roots (incompatible social styles), economic function (creates class distance), faction presence (one is under heavy institutional pressure, the other isn't). + +Cultural distance drives bleed distance inversely: HIGH cultural distance = SHORT bleed zone (cultures resist each other). LOW cultural distance = LONG bleed zone (cultures flow together). + +**Example: Sova Station** +- Transit District ↔ Residential Core: medium cultural distance (similar heritage, different function). Medium bleed zone. Workers commuting between them carry Transit District culture into the Residential Core gradually; Residential Core's domestic culture bleeds back. +- Transit District ↔ Administrative Hub: HIGH cultural distance (Frost-labor culture vs. institutional-authority culture; completely different faction presence tiers). Short bleed zone. The boundary feels hard. +- Residential Core ↔ Commercial Quarter: Low cultural distance (similar population, different commerce level). Long bleed zone. The neighborhood-adjacent-to-commercial-district feels like the commercial district in texture. + +### 5.3 Social Sites at Boundaries as Information Exchanges + +The most interesting NPCs in the game are the ones who live on cultural boundaries. They have access to both cultures. They're trusted by neither completely, which makes them interesting for investigation (they see both sides) and dating sim (their dual identity is its own drama). + +**Generator rule:** The boundary bleed zone should contain at least one social site with MIXED cultural access tiers — where both adjacent district cultures feel equally eligible. This is the border bar, the neutral ground café, the market stall that serves both communities. + +These mixed social sites are generators of: +- Cross-triangle triangles (D-024 cross-template triangles — the members literally live in different districts) +- Translation figures (NPCs who move between cultures — high information value) +- Cultural friction (the place where Heritage Root A and Heritage Root B interact, which means their TRUST MODELS interact — and different trust models produce misunderstandings, offenses, and unexpected alliances) + +### 5.4 Faction Bleed vs. Cultural Bleed + +These are distinct types that behave differently: + +**Faction bleed** decays in a radius from faction infrastructure. A Commission checkpoint creates surveillance culture (NPCs modify behavior) for ~3-5 blocks in any direction, regardless of district boundaries. The formal jurisdiction stops; the social norm doesn't. + +**Cultural bleed** follows foot traffic patterns more than distance. Culture flows along the routes people actually walk. A maintenance corridor that connects two districts of very different cultures is a cultural conduit — the workers who use it daily carry elements of each culture to the other. + +**The generator should model both separately:** +- Faction bleed: a `radius_effect` from faction infrastructure, decaying by block distance +- Cultural bleed: a `flow_path_effect` along actual NPC movement corridors, strongest along high-traffic routes + +This distinction matters for gameplay: the detective can predict faction bleed (it's geometric). Cultural bleed is harder to predict — you have to know how people actually move. + +--- + +## Section 6: Answering Nigel's Question — Cultural Ingredients Space Size + +Nigel asked directly: "How large is the cultural ingredients space? The variety payoff depends on how many distinct ingredient combinations produce distinguishable district personalities." + +### 6.1 The Combination Count + +Let me enumerate this properly: + +**Heritage Root blending** (primary driver of cultural feel): +- 10 roots available +- Select 1-3 (with blend weights at 0.1 granularity for meaningful differences) + - Pure single-root: 10 + - Two-root blends: C(10,2) × ~5 weight distributions = 45 × 5 = 225 + - Three-root blends: C(10,3) × ~10 weight distributions = 120 × 10 = 1,200 + - **Total: ~1,435 meaningfully distinct heritage profiles** + +**Settlement Motivation:** 9 types + NULL = 10 states + +**Economic Function:** 11 types (10 + mixed/diversified) + NULL = 12 states + +**Economic Pressure:** 7 types, pick 0-2 = 1 (null) + 7 (single) + 21 (pairs) = **29 states** + +**Drift Stage:** 4 stages (pioneer / crystallizing / mature / ancient) + +**Faction Presence:** 3 institutions (Commission, Syndic, Assembly/local-governance) × 5 presence levels (comprehensive / standard / intermittent / absent / hostile) = 5³ = 125 combinations; realistically ~30-40 plausible combinations + +The raw combination count is astronomical. But "distinct for the player" is a tighter constraint. + +### 6.2 The Gameplay-Distinguishable Space + +Five parameters drive most of the GAMEPLAY FEEL differentiation: + +| Parameter | Distinguishable states | Driver | +|---|---|---| +| Heritage Root primary + secondary | ~100-150 (blends, accounting for dominance) | Social style, trust mechanism, naming feel | +| Economic Pressure combination | ~20 | Grey economy moral texture | +| Faction Presence tier | ~12 | Investigation difficulty, access structure | +| Drift Stage | 4 | How "foreign" the culture feels | +| Economic Function | ~8 (plus 3 terrain types) | Daily rhythm, investigation vector | + +Rough gameplay-distinguishable space: 100 × 20 × 12 × 4 × 8 = 768,000 combinations before overlap. With a generous overlap factor of ~100× (many combinations produce similar GAMEPLAY even if culturally distinct): **~7,700 meaningfully distinct game-mechanical experiences**. + +**My answer to Nigel:** The ingredients space is not a limitation at 300 worlds. Even with aggressive pruning for plausibility (many combinations are impossible or implausible — a Commission-absent world with comprehensive Meridian coverage, for instance), the playable space exceeds 1,000 truly distinct district personalities. At 300 worlds, we're sampling a small fraction of the available space. + +Nigel's calculation (300 × 2 characters × 20 cultural compositions = 12,000 games) was using the conservative "20 distinct compositions" assumption. The actual distinguishable composition space is ~1,000-7,700+. His calculation scales accordingly: **300 × 2 × 100 minimum cultural compositions = 60,000 meaningfully distinct games before factoring seed variation**. + +**One caveat:** The practical limit is not the combination space but the **authored template library depth**. If we only have 10 D-025 templates, the 100th cultural composition will still draw from the same 10 templates. Cultural variety without template variety means the CULTURAL feel changes but the SPATIAL feel repeats. Template library expansion is the binding constraint, not the ingredients space. + +### 6.3 The DLC Model for Template Expansion + +Setting note — the lead directive mentions "Template packs per DLC is a valid expansion model." This is correct and the ingredients menu makes it tractable. + +**How it works:** +- The base game ships templates for: logistics, residential, administrative, bar/social, maintenance, gate cluster +- DLC pack "Agricultural Worlds" adds: farmstead, granary, rural tavern, market day, seasonal camp, mill complex +- DLC pack "Maritime Settlements" adds: fishing dock, harbor bar, vessel interior, lighthouse, chandlery +- DLC pack "Leisure Economies" adds: resort lodge, surf shack, mountain chalet, seasonal service housing + +Each DLC pack expands which templates are eligible for each ingredient combination. The ingredients menu and society profile remain unchanged — new DLC just extends the template pool that the generator draws from. + +This is the correct DLC model because: players who don't buy the DLC don't encounter broken world generation. They just don't see those setting types. The generator gracefully falls back to base game templates if a DLC template is selected but unavailable. + +--- + +## Summary: What Round 2 Adds to the Generator Architecture + +**New contributions:** + +1. **Playstyle-agnostic information vocabulary** — society profile extended with economic information taxonomy, relationship formation norms (heritage-root-dependent), power structure internals. All playstyles read the same generated landscape through different lenses. + +2. **Non-urban terrain grammar** — same ingredients menu, five new terrain types with different spatial primitives: + - Agricultural: dispersed farmstead clusters, seasonal cycles, expanded D-025 radius limits + - Wilderness: no society profile; resource nodes, traversal terrain, historical markers only + - Small settlements: district = entire settlement; high anonymity risk; inverted information asymmetry + - Maritime: water terrain tiles, vessel as bounded-mobile social site, port node concentration + - Tourist economy: dual NPC population profiles, explicit class contrast, time-bounded visitor relationships + +3. **Insignificant places** — insignificance as a relational parameter, not a content-reduction parameter. The "insignificant place" society profile produces: high drift novelty, high insider threshold, inverted anonymity, intimate conspiracy scale. Distinct content type, not scaled-down hub. + +4. **Edge bleed** — two distinct types (faction bleed = radius-geometric; cultural bleed = flow-path-along-movement-routes). Bleed distance driven by cultural distance function. Boundary social sites as cross-cultural information exchanges. + +5. **Cultural ingredients space size** (answering Nigel) — raw combination space is hundreds of thousands; gameplay-distinguishable space is ~1,000-7,700+ compositions. 300 worlds uses ~5-30% of the available variety. Binding constraint is template library depth, not ingredients space. + +6. **DLC as template library expansion** — ingredients menu stays stable; DLC adds eligible templates per ingredient combination. Correct model for extending to new setting types. + +--- + +**Status:** Round 2 complete. +**Author:** Miri +**Date:** 2026-02-27 +**Questions for Round 3:** +- Gestalt: Does the playstyle-agnostic information vocabulary require changes to the D-025 template spec, or just content tags on template nodes? +- Tyre: Can the `bounded_mobile` social site flag work within the existing chunk architecture? (Vessels move — this might require an entity-carried chunk, which is architecturally complex.) +- Nigel: Does cultural ingredients space calculation satisfy your variety guarantee? Does the binding constraint (template library depth) change your replayability architecture? +- Araminta: Non-urban terrain types need different visual grammar inputs (no zone palettes for wilderness, different material vocabulary for agricultural settings). Are the era-tag and zone-type inputs sufficient to drive visual coherence in these settings, or do we need new parameters? diff --git a/docs/workshops/generator-architecture/miri-round3-supplement.md b/docs/workshops/generator-architecture/miri-round3-supplement.md new file mode 100644 index 000000000..7192c0b0b --- /dev/null +++ b/docs/workshops/generator-architecture/miri-round3-supplement.md @@ -0,0 +1,290 @@ +# Generator Architecture Workshop — Round 3 Supplement: Miri (Worldbuilder) + +**Topic:** Destructible boundaries, vertical social stratification, entity-carried chunks (trains/ships), grid vs. organic by heritage root +**Date:** 2026-02-27 +**Context:** This supplements miri-round3.md, which was filed before the full Round 3 broadcast arrived. Four directives from the broadcast required worldbuilding perspective that the main document didn't address. + +--- + +## Supplement 1: Destructible Boundaries — What's Behind the Wall + +*Lead directive: what's behind a wall the player blows open?* + +This is a pure worldbuilding question before it's a technical one. The answer depends on WHAT THAT WALL IS DOING in the cultural and historical context it was built in. + +### 1.1 Wall Typology — Why Walls Exist + +Walls are built for specific reasons, and the reason determines the worldbuilding content on the other side: + +**Privacy walls** — separating domestic/private space from public/semi-public space. Common in Spice, Jade, and Frost heritage settings. What's behind them: domestic life, family space, private arrangements that weren't meant to be observed. The content is intimate, often mundane, occasionally revealing. + +**Authority walls** — defining institutional territory. What Commission jurisdiction looks like physically: reinforced barriers, access control, clearly marked transitions. Behind Commission authority walls: operational infrastructure (records storage, personnel areas, evidence holding). The content is institutional — paperwork, equipment, records of what the Commission has been doing. + +**Security walls** — concealing high-value assets. Syndic logistics operations, grey economy storage, faction caches. These are built to HIDE something. What's behind them is the thing they were hiding. The content is the grey economy made physical: unlicensed inventory, contraband, documentation of prohibited activities, sometimes people. + +**Structural walls** — load-bearing divisions that weren't primarily about separation but became that. What's behind them depends on era tag: Era 1 walls sealed over may contain original infrastructure (utility runs, passages that were blocked when the era changed), sometimes abandoned equipment or materials left in place when the area was repurposed. + +**Cultural/heritage walls** — built for reasons that only make sense in specific heritage contexts. A Spice-heritage compound wall defines family honor territory. A Frost-heritage barrier between residential and operational is about psychological separation (noise, smell, the intrusion of public life). An Iron-heritage wall around the union hall is a claim of territory. + +### 1.2 Heritage Root and What the Breach Means + +Blowing open a wall is not culturally neutral. What matters is not just what's there but **how the community interprets the act of breach**. + +**Frost heritage:** Walls are the physical expression of privacy as a value. Breaking through one is a profound violation regardless of what's found. A Frost community will respond to an unauthorized breach even if the contents are innocent. The breach itself is the offense. For the investigator or assassin: entering a Frost space through a destroyed wall marks them as someone who doesn't respect boundaries — which in a Frost culture is a serious social flag. + +**Iron/Dust heritage:** Walls define collective territory. Breaching a wall into a union hall or communal storage means intruding on the group's shared space — it's an attack on the collective. The community responds collectively. The content behind the wall (collective resources, organizing records, mutual aid supplies) is community property, and damaging access to it is an attack on the community. + +**Spice heritage:** The family compound wall is honor-adjacent. A breach is an insult to the family. What's inside is family-private space — domestic, intimate, potentially containing the family's most protected relationships and secrets. The breach creates a vendetta obligation in some Spice-heritage contexts. + +**Salt heritage:** Walls are contractual, not sacred. If you breach a wall, you owe something for it. The content is commercial inventory, records, assets. A Salt community may tolerate the breach if appropriate compensation is offered. They'll want payment. + +**Arc heritage:** Walls around intellectual/operational spaces contain RECORDS. The Arc community will care intensely about the integrity of what's behind — not the breach itself but the potential disruption to ordered knowledge. Behind an Arc wall: labeled storage, research records, documentation of ongoing work. The content is information in organized form. + +### 1.3 Era Tag and Physical Contents + +The era tag on the wall's block determines what's behind it structurally: + +**Era 1 wall (sealed over time):** The oldest sealed spaces contain the building's original purpose. In a station, this means the infrastructure of earliest construction — original transit passages, the plumbing and electrical paths laid before the upper layers, sometimes abandoned rooms that were simply walled off when the superstructure changed. Content includes original structural materials (different from the visible surface), possibly Era 1 equipment abandoned in place, historical markers (founding dates, workers' marks in the walls, construction refuse). + +**Era 2 wall (intermediate period):** This was sealed during the commercial/operational expansion. Behind it: infrastructure supporting an intermediate-era function that's no longer visible from the current space. A logistics terminal that became a residential corridor — the Era 2 wall might conceal cargo-bay equipment, commercial-grade storage installations, commercial transit systems. Also: the era transition often produced conflict. Era 2 walls may seal spaces that were abandoned during economic disruption — with the contents of that disruption preserved. + +**Era 3 wall (recent):** Recent walls are planned. What's behind them is known — or should be. The absence of official knowledge about Era 3 content is itself suspicious. A recently sealed wall in an active district with no official record of what's there: someone sealed something deliberately and recently. + +### 1.4 Generator Requirement + +**Every block face that can be breached must carry a `behind_boundary` descriptor:** + +```yaml +behind_boundary: + content_type: private_domestic | authority_operational | economic_storage | + structural_original | abandoned_era | active_concealment + era: era1 | era2 | era3 + cultural_sensitivity: low | medium | high | extreme + # extreme = Spice family compound; Iron union hall; Frost private residential + contents_hint: null | infrastructure | records | inventory | persons | evidence + breach_consequence: + immediate: null | alarm | NPC_response | environmental_hazard + social: none | community_sanction | faction_response | vendetta_trigger +``` + +The `behind_boundary` descriptor is generated at Phase 1 (district skeleton / block planning) and stored in the `BlockSkeleton`. It is NOT revealed to the player until the wall is breached — but it informs the generation of what gets spawned when the breach occurs. + +--- + +## Supplement 2: Vertical Scale — The 50-Floor Skyscraper + +*Lead directive: how does a 50-floor skyscraper emerge from the generator?* + +### 2.1 Why Vertical Space Exists in Specific Cultures + +Vertical architecture is not culturally neutral. Cultures build tall for different reasons, and the reason shapes the internal vertical social organization: + +**Corporate/Syndic cultures** build tall for efficiency and status display. The building is an asset and a symbol. Height signals wealth. This creates the classic vertical hierarchy: lobby (public/commercial), office floors (operational), executive floors (top). Frost + corporate economic function produces this pattern reliably. + +**Institutional/Commission cultures** build tall for administrative organization and security. The building is infrastructure. Height creates defensible institutional core. The pattern is different: lower floors are public-facing (reception, public services), middle floors are operational, upper floors are not executive suites but records and security operations. Access tightens as you go up. + +**Labor/Iron cultures** do NOT build tall by choice. Vertical housing is often imposed by economic constraint (urban density) or by prior ownership (inhabiting a building built by a different cultural actor). Iron-heritage communities in vertical buildings create horizontal solidarity networks across floors — the floor as community unit, not the building as hierarchy. + +**Spice-heritage communities** adapt vertical architecture to family organization. The extended family may occupy multiple floors of the same building with internal connections between floors — a vertical family compound. Outsiders may occupy other floors of the same building with no relationship to the Spice-family floors. + +### 2.2 The Vertical Social Hierarchy + +The Z-level model established in D-093 (z=0 maintenance, z=1 operational, z=2 institutional/gate) maps onto vertical buildings differently than onto the station's broader district structure. + +In a tall building within a station or urban setting: + +| Floor range | Social function | Typical occupants | Cultural variation | +|---|---|---|---| +| Ground (z=0 equiv.) | Public-facing commerce or passage | Anyone; high traffic | Little — street interface is universal | +| Lower floors (z=1-3) | Work functions, commercial operations | Workers, service functions | Heavy — Iron cultures push worker facilities down; Frost cultures push worker facilities down differently (clean separation) | +| Mid floors | Administrative, secondary residential | Mixed | Moderate — depends on economic function | +| Upper floors | Residential (valuable) OR operational (secure) | Wealth OR security | Strongest cultural variation here | +| Top floors | Penthouse OR secure operations | Varies entirely by culture | Frost-corporate = executive; Commission-Jade = secure records; Arc = observatory/library; Iron = reclaimed community | + +**The important conflict:** In station architecture, z=0 is MAINTENANCE (lowest status); z=2 is institutional/gate (highest). In standard tall buildings, the top floor is highest status. When a tall building is grafted onto station spatial logic, these conventions can CONFLICT — creating the visible cultural tension of "who actually has power here?" The building where the maintenance workers are on the same floor as the executive offices (because the executive floor is near the gate infrastructure) tells you something specific about this station's power structure. + +### 2.3 Generator Requirements for Vertical Scale + +The current D-094 hierarchy (region/district/block/chunk) handles z-levels via stacked chunk layers. A 50-floor skyscraper is not 50 district-level z-levels — it's 50 chunk-stack layers within a SINGLE block footprint. + +**Setting note for Tyre:** A skyscraper is a multi-z-level single-block structure with: +- One or more block footprints on the ground level +- N chunk layers stacked, each 64×64 tiles, each z-level a floor +- The social profile of the BUILDING follows the vertical hierarchy rules above +- Each z-level chunk inherits era tags from the block but has a separate `floor_function: FloorFunction` field + +The generator needs to know, at block planning time (Phase 1 Stage 3), whether a block is a TOWER BLOCK — flagged to generate multiple z-levels rather than a single operational level. This is a `BlockSkeleton` property: `tower: Option<TowerConfig>` where `TowerConfig` records number of floors, floor function assignments, and the vertical social profile derived from the society profile. + +**The worldbuilding content per floor is derived from the vertical social hierarchy table above.** The generator applies the table with heritage root modifiers. An Iron-heritage corporate building and a Frost-heritage corporate building with the same number of floors will have their social zones distributed differently. + +### 2.4 What Makes the Skyscraper Interesting Gameplay-Wise + +**Vertical information asymmetry:** People on the upper floors have information about what happens on the lower floors (they commissioned it, they receive reports, they ordered it). People on the lower floors have information about the upper floors that upper-floor residents don't know they have (maintenance workers know what equipment is running, what's being shipped, who visits). The skyscraper is a compressed information gradient. The investigator ascending through a corporate tower is climbing the information ladder physically. + +**Cross-floor social triangles:** A triangle where NPC A is on floor 3, NPC B is on floor 17, and NPC C is on floor 42 is a cross-floor triangle. The staging ground challenge is different — the player needs ACCESS to multiple floors to work the triangle. The access tier system maps to floors in a tower, not just horizontal zones. + +**The assassination tower problem:** A high-value target on the 42nd floor has an implied protection architecture: limited vertical access, controlled entry points, security in the vertical corridor. The assassin's problem is a vertical architecture puzzle. Escape routes are the same — you can go down but not sideways until you exit the building. The informal zone in a corporate tower is the maintenance infrastructure (utility shafts, service elevator, roof access). + +--- + +## Supplement 3: Entity-Carried Chunks Beyond Vessels — Trains and Spaceships + +I covered maritime vessels in Round 2. Round 3 makes entity-carried chunks CORE and adds trains and spaceships. The worldbuilding differs meaningfully per vehicle type. + +### 3.1 Trains — The Bounded Linear Society + +The maritime vessel is bounded-spherical (you can move throughout the vessel, which is a small total space). A train is **bounded-linear** — you can move through it from end to end, but the ends are as far apart as they are. + +**The train's social grammar:** + +Trains compress multiple social tiers into a physical sequence. The typical car sequence (from front/premium to rear/economy): observation/premium → standard passenger → general class → cargo. This is a PHYSICAL EXPRESSION of the social hierarchy that the player can observe simply by walking through the train. + +Heritage root variation on train social organization: +- **Frost heritage passenger car**: minimal interaction, compartmentalized seating, high privacy expectation. Everyone minds their own business. Information is not shared. +- **Tide heritage passenger car**: communal orientation, groups eat together, children move between cars, conversation across compartment boundaries is normal. Information flows freely — and includes information about everyone on the train. +- **Iron heritage work train** (carrying labor to a site): collective space, political awareness, workers know each other, mutual solidarity active. An outsider on an Iron labor train is immediately noticed. + +**What makes trains distinctive for gameplay:** + +- **Temporal pressure**: the destination is known and approaching. Whatever needs to happen on the train must happen before arrival. +- **Witness compactness**: everyone who matters to the scenario is on the same vehicle. No one leaves. Cross-examination and confrontation are possible in ways that aren't in open-world settings. +- **Social compression**: the shared experience of travel creates artificial intimacy. Dating sim dynamics accelerate on trains because you're physically in close proximity with the same people for extended periods. +- **Information lock**: what happens on the train is sealed until arrival. The investigation play on a train is fully contained — but also can't get outside support. +- **Assassination on a train**: a CLASSIC scenario type for a reason. The target can't escape. The witnesses can't leave. The aftermath begins at arrival. The assassin's problem is managing all of this in a confined linear space. + +**Generator requirement for trains:** +`SettingType::BoundedLinear` — a variant of the `bounded_mobile` tag. The chunk is mobile, carried by an entity (the train), and has a **linear access topology** (you can progress from one end to the other, with optional locked compartments). The social site tags on a train are car-typed: `premium_car`, `general_car`, `cargo_car`, `service_car`, each with its own social density and access rules. + +### 3.2 Spaceships — The Total Information Environment + +A spaceship in transit is the most extreme information asymmetry environment in the setting. Not because it's bounded (so is a vessel or a train) but because it is **between systems**. + +**What "between systems" means culturally:** + +During interstellar transit via the horizon gate network, the ship is in the gate conduit — physically in neither origin nor destination system. Normal institutional jurisdiction is suspended (which Commission has authority in transit?). Normal communication with external parties is interrupted (no message traffic possible in transit). The crew and passengers are alone in a way that no other setting achieves. + +The sociological effect: **transit strips social roles**. The powerful person on the ship cannot call for backup. The institutional authority figure cannot enforce through external threat. The information bottleneck is total — nothing you know or reveal can exit the ship until arrival. + +Heritage root behavior during spaceship transit: +- **Frost**: doubles down on privacy. In a suspended institutional environment, the default is LESS interaction, not more. Information is even more guarded. +- **Tide**: expands. The community of the ship becomes the relevant community for the duration. Social bonds form faster. Information flows within the ship-community. +- **Arc**: the transit period is time for discourse. The suspended institutional context is an opportunity for conversation that wouldn't happen with normal social stakes present. +- **Salt**: commerce that couldn't happen under normal jurisdiction now can. The transit period is a grey-market window. + +**The spaceship social site types:** +- **Crew quarters** — insider space (crew only), the social hub of the working community +- **Passenger areas** — mixed access, varying by class of ticket +- **Bridge/operations** — institutional space, crew credentials required +- **Cargo hold** — the informal zone equivalent (no social occasion for most passengers to be there) +- **The airlock** — the most extreme "place too small to be safe" (Ozzie's spatial archetype #5) + +**Assassination on a spaceship**: extreme in both directions. The target CANNOT ESCAPE until arrival. But neither can the assassin. Evidence management is impossible — there's nowhere to dispose of evidence that won't be found when the ship docks. Post-arrival institutional scrutiny begins immediately. The spaceship assassination is only viable if the investigation can be controlled at destination, which means the assassin needs assets waiting at the other end. + +### 3.3 The Cultural Grammar of Transit + +Across all entity-carried chunks (vessel, train, spaceship), one cultural constant applies: **transit reveals character**. The normal social infrastructure that regulates behavior (institutional enforcement, community surveillance, professional role performance) is attenuated in transit. What people do when the normal constraints are loosened tells you who they actually are. + +This is the deepest gameplay value of entity-carried chunks: not just bounded space for investigation, but a setting where the information asymmetry becomes especially acute because everyone's masks slip a little. + +**Generator rule:** Entity-carried chunks should have a `transit_social_modifier` that adjusts: +- `trust.building_rate`: increases (forced proximity) +- `social.privacy_level`: decreases (can't leave) +- `cultural_norms.enforcement_level`: decreases (institutional authority attenuated) +- `information_flow.internal`: increases (nowhere else to talk) +- `information_flow.external`: blocked until arrival + +--- + +## Supplement 4: Grid Breathing — Which Cultures Produce Which + +*Lead directive: BOTH grid and organic. Some blocks are grid, some organic chaos. Architecture must support both.* + +This is a worldbuilding question with a clear answer: **grid vs. organic is determined by who built it, when, and under what power conditions**. + +### 4.1 Grid = Power Imposed; Organic = Power Negotiated + +**Grids emerge when:** +- A single authority planned the space before construction +- The authority had sufficient power to enforce the plan throughout construction +- The time pressure was low enough for systematic planning +- The cultural value is ORDER as inherently correct + +**Organic patterns emerge when:** +- Multiple actors built incrementally over time +- No single authority had full control +- Time pressure forced immediate construction without planning +- The cultural value is FUNCTION over form +- The terrain demanded deviation (natural features that planners worked around) + +This means the district's **founding conditions** are the primary driver: + +| Founding condition | Grid tendency | Organic tendency | +|---|---|---| +| Commission-planned | STRONG | Weak | +| Syndic corporate development | Strong | Weak | +| Colonial imposition | STRONG | Weak | +| Worker/labor organic settlement | Weak | STRONG | +| Frontier/pioneer | Weak | STRONG | +| Spice family compound networks | — | Medium (family logic, not geometry) | +| Arc institutional design | Strong | — | +| Gradual accretion over eras | Weak | STRONG | +| Single development event (urban planning) | STRONG | — | + +### 4.2 Heritage Root and Grid Preference + +| Heritage Root | Preference | Why | +|---|---|---| +| **Frost** | Grid (operational zones), organic (residential) | Work is organized; private life doesn't need to be legible to others | +| **Tide** | Organic | Community space evolves from social patterns, not planning | +| **Iron** | Organic | Workers built their own spaces; no planner controlled the process | +| **Spice** | Semi-organic | Follows family network logic — clusters, not grids | +| **Jade** | Grid with deliberate irregularity | Order appreciated; but variety is necessary for beauty | +| **Dust** | Organic | Survival-driven; build what you need, where you need it | +| **Vine** | Organic | Social warmth = built space following relationship patterns | +| **Salt** | Grid (commercial zones) | Commercial clarity; buyers need to find sellers | +| **Stone** | Semi-grid | Permanence favors planning; but generational accretion adds organic layers | +| **Arc** | Grid | Rational organization is a core value | + +### 4.3 The Within-District Grid/Organic Mix + +**This is where it gets interesting for the generator.** A single district can contain BOTH grid and organic sections, because: + +1. **Era stratification**: Era 1 sections are organic (pioneer/frontier construction). Era 3 sections are grid (planned development). The same district has the palimpsest of both. + +2. **Economic function zone differentiation**: Commercial and institutional zones (Syndic, Commission) are grid; residential and maintenance zones are organic. The commercial street is straight; the workers' housing behind it is a warren. + +3. **Cultural overlap**: A district with mixed heritage (Salt-commercial + Iron-residential) has grid commercial blocks and organic residential blocks. The edge bleed between them is the zone where the two grammars fight. + +4. **Power vacuum areas**: Spaces that no authority planned — because no authority wanted them — are organic. The informal zone is almost always organic because it emerged from use, not planning. + +**Generator rule:** +The `BlockSkeleton`'s `ChunkLayout` already handles L-shapes and merged footprints. The grid vs. organic distinction is upstream: at block planning time, each block should be assigned a `street_geometry: GridAligned | OrganicDeviation` property. Adjacent blocks can have DIFFERENT street_geometry values, creating the within-district mixing the lead directive requires. + +What drives the assignment: +- Era 1 blocks: OrganicDeviation default (unless founded by a specific planning authority — Colony = GridAligned even in Era 1) +- Era 2 blocks: weighted by economic function (commercial/institutional → GridAligned; residential/maintenance → OrganicDeviation) +- Era 3 blocks: weighted by faction presence (Commission/Syndic presence → GridAligned; absent → depends on drift stage) +- Cultural heritage overlay: Arc/Frost-operational/Salt → weight toward GridAligned; Iron/Dust/Tide/Vine → weight toward OrganicDeviation + +**The street grid rotation (Ozzie's persistent demand):** +Two adjacent districts with different orientations are plausible when: the districts were planned by different authorities at different times, or when terrain features forced different orientations. A Commission-built district planned along the station's primary axis is GridAligned at 0°. An organically grown district adjacent to it, predating the Commission expansion, may have a street orientation that aligned to the first permanent structure (the original tavern, the first cargo bay), not the Commission's axis. This produces the 15-23° rotation Ozzie is asking for. + +**The generator needs to track not just GridAligned vs. OrganicDeviation but `street_orientation: f32` (rotation in degrees from the district cardinal axis).** GridAligned blocks inherit 0° from the district plan. OrganicDeviation blocks can deviate ±30° based on historical founding conditions. + +--- + +## Summary of Supplement + +**Destructible boundaries:** Behind walls is heritage-dependent and era-dependent. Privacy walls (Frost/Spice/Jade) protect social/cultural content; security walls protect economic content; structural walls contain historical content. Each breach has a `cultural_sensitivity` rating and a `social_consequence` that fires based on cultural context, not just what's found. Requires `behind_boundary` descriptor on every block face. + +**Vertical scale:** A 50-floor skyscraper is a multi-z-level single-block tower with a vertical social hierarchy. That hierarchy is heritage-root-derived: corporate culture = bottom labor / mid operations / top executive; Iron-heritage occupation = collective floor reclamation; Commission-institutional = public access at base, secure operations at top (inverted from corporate). Requires `TowerConfig` in `BlockSkeleton`. + +**Entity-carried chunks (trains + spaceships):** Trains are bounded-linear with class-sequence social grammar. Spaceships are institutionally suspended environments — the most extreme information asymmetry because jurisdiction, communication, and social enforcement are simultaneously absent. Both types have a `transit_social_modifier` that adjusts trust-building rate, privacy level, and information flow. What people do when constraints are lifted reveals character. + +**Grid breathing:** Grid = power imposed (Commission/Syndic planning, Arc heritage, Era 3 development). Organic = power negotiated (Iron/Dust/Tide settlement, Era 1 foundations, maintenance/residential zones). Within a district, BOTH can and should appear in different blocks. `street_orientation: f32` enables the inter-district grid rotation Ozzie is demanding — organically-settled districts have orientations inherited from their first permanent structure, not from the Commission's coordinate system. + +--- + +**Author:** Miri +**Date:** 2026-02-27 +**Status:** Supplement to Round 3 — filed separately to keep Round 3 focused. diff --git a/docs/workshops/generator-architecture/miri-round3.md b/docs/workshops/generator-architecture/miri-round3.md new file mode 100644 index 000000000..fbf5aae95 --- /dev/null +++ b/docs/workshops/generator-architecture/miri-round3.md @@ -0,0 +1,520 @@ +# Generator Architecture Workshop — Round 3: Miri (Worldbuilder) + +**Topic:** Assassination playstyle, wilderness informal zones, palette granularity, playstyle affinity per world type, insignificance as social lens, dynamic world modification +**Date:** 2026-02-27 +**Source:** All Round 2 outputs, Qatux Round 2 notes, lead directives + +--- + +## Reading the Round 2 Outputs + +The team convergence is solid. The two-phase pipeline is correct. The playstyle-agnostic information landscape framing from my Round 2 held. Gestalt's 11-check guarantee audit is the right enforcement mechanism. Tyre's data structures are handling the extension well. + +The new territory for Round 3 is deeper. Six directives, none of them cosmetic. I'll take them in order. + +--- + +## Section 1: Assassination — The Sixth Playstyle + +The lead gives a specific example: Frost cultures make targets harder to track, easier to operate unseen. Dust cultures make strangers visible and targets visible in equal measure. This is the right frame. Let me build it out fully. + +### 1.1 What Assassination Needs From the Information Landscape + +The assassin's information challenge is distinct from all four other playstyles: + +| Playstyle | Primary question | Information goal | +|---|---|---| +| Investigation | Who did it? | Find the truth | +| Tycoon | Where is the value? | Find the opportunity | +| Dating sim | Who is this person? | Form the bond | +| Political drama | Who controls what? | Find the leverage | +| **Assassination** | **Where is the target, when?** | **Find the window** | + +The assassin is not looking for truth or leverage. They're looking for **pattern and vulnerability** — when does the target deviate from their protected routine? What single moment of exposure exists? And critically: what does the aftermath look like, and who investigates it? + +This gives assassination a unique three-phase structure: +1. **Pattern acquisition** — learn the target's movements, associations, protection +2. **Window identification** — find the gap in protection that can be exploited +3. **Aftermath management** — control the information environment after the fact + +Every phase is shaped differently by cultural ingredients. + +### 1.2 Heritage Root Effects on Assassination + +The society profile's heritage roots drive behavioral norms, trust models, and information flow patterns. For assassination, the relevant variables are: + +**Observational density** — how much does this community track strangers and report deviations? +**Information liquidity** — how freely does knowledge of the target's movements circulate? +**Pattern rigidity** — how predictable are people's routines in this culture? +**Aftermath engagement** — how hard does this community investigate when something goes wrong? + +| Heritage Root | Observation density | Information liquidity | Pattern rigidity | Aftermath engagement | +|---|---|---|---|---| +| **Frost** | Low (people don't watch others) | Very low (information doesn't trade) | HIGH (rigid schedules, functional predictability) | LOW (private grief, informal inquiry) | +| **Tide** | High (community awareness is a social virtue) | High (social information flows freely) | Low (fluid schedules, social spontaneity) | HIGH (communal justice demand, collective response) | +| **Iron** | Medium-high (labor solidarity = mutual monitoring) | High within group, low to outsiders | Medium (shift patterns are predictable; solidarity gatherings are not) | HIGH (collective accountability culture) | +| **Spice** | High within network, zero outside it | Low to strangers, high within family | Medium (family events are predictable; individual movement less so) | VERY HIGH (family networks activate, honor obligations) | +| **Jade** | High (aesthetic tradition = careful observation) | Low (discretion is a virtue) | Medium (ritual predictability, but private movements are concealed) | Medium (formal inquiry, institutional channels) | +| **Dust** | VERY HIGH (survival = mutual awareness) | High (shared information is survival) | Medium (community events are predictable; individual less so) | High (community protection response) | +| **Vine** | High (warmth = social tracking) | Very high (gossip is connection) | Low (social spontaneity, relationship-driven schedules) | Medium-high (personal response, not institutional) | +| **Salt** | Low (pragmatic, not nosy) | High IF there's benefit (transactional information) | Medium (deals create predictable windows; personal schedules do not) | Medium (pragmatic inquiry, proportional response) | +| **Stone** | Medium (guardianship tradition = watching what matters) | Low (protective silence) | HIGH (traditional rhythms, seasonal predictability) | Medium (steady, persistent, not explosive) | +| **Arc** | Low (intellectual focus, not social surveillance) | Medium (ideas traded freely; personal information less so) | LOW (intellectual schedules are chaotic, spontaneous) | VERY HIGH (documentation, inquiry, institutional investigation) | + +**The key assassination matrix:** + +A **Frost-dominant** setting is the assassin's operational paradise but intelligence nightmare: +- You can stand in a corridor for an hour and no one will acknowledge you (low observation) +- You cannot buy information about the target's movements (low liquidity) — you must observe directly +- The target's routine IS reliable once you have it (high pattern rigidity) — patience pays +- The aftermath will not mobilize the community (low engagement) — your window to leave is long + +A **Tide-dominant** setting is the assassin's intelligence gift but operational horror: +- Everyone notices you've been asking about this person (high liquidity — the information you collected is also shared about you) +- The target's movements are discussed openly at the bar — you can learn their schedule in two conversations +- Social spontaneity means the schedule shifts around social invitations — the window you identified may not repeat +- After the act, the community mobilizes fast and personally — they remember you, your face, your accent + +**Dust** (the lead's example) gives the assassin perfect target information and terrible cover: +- Strangers are events in Dust communities — you are visible from the moment you arrive +- But so is the target — their deviations from routine are noticed and discussed +- The assassination window exists when the target is physically separated from the community (rare in Dust culture, which values collective presence) +- The ideal Dust community assassination looks like an accident or external threat — not an insider job, because the community will know every insider + +**Arc** communities produce the most dangerous aftermath: +- The community may not notice you much during pattern acquisition (low social surveillance) +- But after the act, an Arc community will investigate with INSTITUTIONAL RIGOR — they document everything, they demand explanation, they produce papers +- An assassination in an Arc community is an intellectual puzzle that will be solved eventually — the assassin must think three moves ahead on information management + +### 1.3 Economic Pressure and Assassination + +The society profile's economic pressure combination shapes WHO can be hired, who keeps secrets, and what the aftermath looks like. + +**`tight-margin + prohibition-economy`** (Sova-type): +- Information CAN be purchased (grey economy includes information brokerage) +- Target protection is degraded (enforcement agencies are compromised or underfunded) +- Witnesses can be bought off or scared off (economic desperation creates leverage) +- Aftermath: Commission investigation is perfunctory unless the target was politically important + +**`survival-gap` economic pressure**: +- No one will pay for truth if they need the money for food +- Witnesses don't come forward (risk too high, reward too low) +- But: the assassin's own expenditure is highly visible (spending above local norms in a survival-gap setting is a red flag) + +**`opportunity-disparity`** (extreme wealth gap): +- High-value targets are surrounded by economic resources that buy protection +- But also: private security is purchasable, which means it can be bribed or subverted +- Information about rich targets circulates among their service class (domestic workers, personal staff) — a different access route + +### 1.4 Faction Presence and the Kill Window + +The faction presence tier directly determines Meridian coverage, patrol patterns, and institutional response capability. For assassination: + +- **Commission comprehensive coverage**: kill window requires understanding the Meridian coverage map, patrol rotation, and response time. Maximum institutional risk but the coverage pattern is ultimately predictable (it's bureaucratic) +- **Commission intermittent/absent**: lower monitoring but also lower deterrence of competition — other actors are also operating freely, which means witness control is harder +- **No faction presence**: the informal social monitoring (community surveillance via Dust/Tide/Iron norms) is the only enforcement. Can be lower or HIGHER than formal coverage depending on community + +The most dangerous assassination environment: **Commission absent + Dust-dominant culture**. No formal coverage, but 100% community awareness. Every local is an alert system and a potential witness who will talk. + +### 1.5 Generator Requirements for Assassination + +For assassination gameplay to work, the generator must produce: + +1. **Target pattern legibility** — the NPC's routine must be knowable through observation/inquiry. This is already built into the 10-axis NPC model (Pattern axis). The generator needs to ensure the target's routine intersects with observable spaces. + +2. **Window geometry** — at least one location on the target's regular route where institutional coverage gaps, access topology creates opportunity, and witness density drops. This is the "informal zone" of the assassination variant — not a grey market space but a VULNERABILITY WINDOW in the target's pattern. + +3. **Information gradient** — the assassin's intelligence-gathering journey should require working up an information ladder. The right D-025 template placement means: the bar gives you rough schedule (low tier), the insider contact gives you specific route (mid tier), the compromised handler gives you the exact window (high tier). + +4. **Aftermath management geometry** — the route out must exist. The generator's access topology serves this: corridors that exit districts, spans to other zones, the maintenance corridors that aren't on the official map. Assassination requires the same informal zone infrastructure as investigation — for different reasons, but structurally identical. + +**Cultural ingredient playstyle tag for assassination:** `assassination_difficulty: low/medium/high/extreme` + +Driven by: observation density × information liquidity × aftermath engagement. High-Frost/low-community settings = low difficulty. High-Dust/Tide + Iron + Arc community settings = extreme difficulty. This is a single derived value from the society profile, not a new field — but it should be computed and stored as a descriptor. + +--- + +## Section 2: Wilderness and Maritime Informal Zones (OQ-R3-C) + +Gestalt raised this question and I'm the right person to answer it: *what does "private space" mean in wilderness without institutional authority?* + +### 2.1 The Redefinition + +In institutionally-governed urban settings, the informal zone is defined by **institutional absence**: spaces outside Meridian coverage, off official maps, not patrolled. + +In non-institutional settings, the informal zone must be redefined. The relevant privacy mechanism is not institutional but **social**: the zone is private not because no camera watches it, but because **social norms give permission for private behavior here** or **community observation doesn't extend here**. + +The shift: from *outside institutional surveillance* to *outside community social field*. + +Every community, regardless of institutional presence, has a **social field** — the range of spaces where community members expect to observe and be observed by others, where social norms apply with full force. The informal zone equivalent is the edge or outside of that field. + +### 2.2 Terrain-Specific Informal Zones + +**Fishing village:** + +The fishing village's social field is strongest at: the dock (arrival/departure, the whole village watches), the meeting hall/tavern (the community gathers here), the boat maintenance area (workers observe each other). + +The informal zone equivalents: +- **The boats during active fishing** — out of sight of shore, away from community. What happens on the boat is governed by the crew, not the village. The horizon is the social boundary. +- **The smokehouse and drying racks** — utilitarian, smells keep people away, solitary work is normal here. The community doesn't question time spent in the smokehouse. +- **The tidal zone before dawn** — the pre-work hours before the village wakes. Low community surveillance because low community presence. +- **The processing shed at the edge of settlement** — the "rough work" zone that social convention keeps separate from the community center. + +**On a farm:** + +The farm's social field is strongest at: the communal fields during harvest (many workers present), the farmhouse (family space), the market day gathering. + +The informal zone equivalents: +- **The far fields in the off-season** — physically distant, not actively worked, no reason to be there +- **The root cellar / storage buildings** — utilitarian storage, no social occasion for lingering +- **The property boundary fencerows** — the edges of someone's land that no neighboring farmer has reason to cross +- **The barn at night** — animals require tending at night, but it's solitary work. "In the barn late" is not suspicious; it's routine. The barn is the farm's informal corridor. +- **The water source / irrigation control point** — critical infrastructure, but visited briefly. Anyone at the sluice gate can claim they're checking the flow. + +**In deep forest:** + +True wilderness has no social field to escape. But within wilderness, informal zones still exist — they're defined by **navigability and customary use**: +- The unmaintained path vs. the maintained one — the unmaintained path is not watched because no one has reason to use it +- The old-growth stands — superstition, impractical footing, and the absence of resources people want means old growth is often socially avoided +- The abandoned structures — the reason for abandonment shapes the avoidance. A burned-out farmhouse is left alone for cultural/emotional reasons. An abandoned mine shaft is avoided for practical safety reasons. Both create informal privacy. +- The viewshed reversal — on a hilltop, you can see anyone approaching from a great distance before they see you. In wilderness, HIGH GROUND is the informal zone equivalent because it provides warning rather than concealment. + +### 2.3 Cultural Heritage and Informal Zone Character + +What the community considers "private by convention" varies enormously by heritage root: + +**Frost communities:** The entire individual's domestic space is their informal zone. "Minding your own business" extends to not noticing where neighbors go. In a Frost fishing village, the informal zone is effectively wherever you are when you're not in the communal space — individual movement is private by default. + +**Tide communities:** Community norms actively cover certain behaviors. "The boat is the boat" — what happens on your boat is your crew's business. The informal zone is defined by crew/family/close-trust unit rather than physical space. + +**Iron communities:** The informal zone is often LABOR-ASSOCIATED. "What workers do after the shift" or "what happens in the union hall" is covered by solidarity norms — community members don't inform on each other to outsiders. The informal zone is social rather than geographic. + +**Dust communities:** The informal zone is almost impossible to find because the community's survival-level social awareness covers everything. The most private you can be is "agreed to be unobserved" — a social contract with specific individuals to not see what you're doing. The informal zone is a negotiated privacy, not a geographic one. + +### 2.4 Generator Rule for Non-Urban Informal Zones + +**For every non-urban Full-complexity district:** + +The `terrain_informal_zone` (Gestalt's proposed term) is not just a geographically sheltered space. It is a space where: +1. Community norms give permission for private behavior, OR +2. Physical distance or conditions reduce community observation without violating social convention, OR +3. Utilitarian function provides social cover for presence ("I'm in the barn checking the animals") + +The generator should produce at least one such space per non-urban Full-complexity district, tagged with its informal zone TYPE: +- `social_permission` — convention covers this space +- `physical_distance` — community observation doesn't reach here without intent +- `utilitarian_cover` — normal function provides plausible presence + +The cultural heritage root should weight which type appears: +- Frost → `physical_distance` (individual space is respected everywhere) +- Tide/Dust → `social_permission` (negotiated privacy within community framework) +- Iron → `utilitarian_cover` (labor function covers presence) + +--- + +## Section 3: Palette Granularity — Society Profile Within Terrain Types + +The question: is 5 non-urban terrain palettes enough? Should a Frost farm look different from a Vine farm? + +**Setting note — this is a base game concern, not DLC.** + +The cultural ingredients system's entire value proposition is that heritage roots produce distinguishable LIVED ENVIRONMENTS. If all farms look alike regardless of heritage, we've built a system that differentiates culture at the behavioral level but flattens it at the spatial level. That contradiction is visible to players. + +The correct model: **terrain type drives the MATERIAL VOCABULARY** (what materials exist here); **heritage root drives the ORGANIZATIONAL GRAMMAR** (how those materials are arranged, decorated, and related to each other). + +### 3.1 The Two-Layer Palette Model + +**Layer 1 — Terrain Base Palette (from Araminta's 5-6 types)** + +This determines what's available: the substrate materials, the agricultural products, the structural materials indigenous to this terrain and climate. A farmland palette has: dark soil brown, amber grain fields, wood structures, stone foundations. This doesn't change with heritage root. + +**Layer 2 — Heritage Grammar Overlay (from society profile)** + +This determines how the base palette is organized and expressed: + +| Heritage Root | Organizational principle | Visual signature | +|---|---|---| +| **Frost** | Functional efficiency. No waste, no ornament | Regular spacing, minimal color variation, equipment stored compactly. No decorative elements. Clean lines on structures. | +| **Vine** | Social warmth. The human scale matters | Gathering spaces woven into work spaces. Decorative climbing plants on structures. Informal seating clusters. Warm accent colors at door/window frames. | +| **Stone** | Permanence. This is built to last | Heavier construction materials. Thick-walled structures. Boundary markers that are permanent (stone walls, not wire fences). Generational accumulation visible. | +| **Tide** | Flow and gathering. People move through | Wide paths between buildings. Cleared central areas for assembly. Structures face toward communal center, not away. Seasonal decoration traditions. | +| **Iron** | Collective utility. Shared is better | Shared infrastructure (communal granary, shared equipment storage). Buildings of similar scale (no grand farmhouse dominating small workers' quarters — or that contrast IS the political statement). Signs of organized labor. | +| **Dust** | Hardship resilience. Nothing wasted | Patched and repaired materials in visible use (not distressed for aesthetics — actually maintained because you can't afford replacement). Water conservation infrastructure prominent. Weatherproofing as primary aesthetic. | +| **Spice** | Family honor expressed physically | Family identifier markers on structures. Distinct zones for family/guest/worker (not mixed). Aesthetic investment in the family-facing spaces (the façade toward the road; the interior family court). | +| **Salt** | Transactional efficiency | Clear entry/exit points. Storage and sale infrastructure visible and accessible. Pricing/scale equipment present. Minimal personal expression — the space is for business. | +| **Arc** | Intellectual order | Things categorized and labeled (even physical objects). Improvement projects visible (new plantings testing yield, experimental plot separate). Written records visible (staked labels, weather logs on walls). | +| **Jade** | Refined appreciation | Careful curation of aesthetic elements. Not more material, but better selected. The fence posts are planed smooth. The path is laid with intentional stone selection. Quality over quantity. | + +### 3.2 What This Looks Like in Practice + +**Frost-heritage farm vs. Vine-heritage farm (same terrain palette, different grammar):** + +*Frost farm:* +- Low wire fences (functional, minimal material) +- Equipment stacked efficiently near work areas, not stored in a dedicated building +- No communal spaces outside — why would you gather outside if there's work? +- Lighting: work-temperature functional, no ambient warmth +- The farmhouse interior has warmth; the exterior presents nothing + +*Vine farm:* +- Trellises repurposed as social dividers between plots (boundary AND aesthetic) +- A planted-out area near the farmhouse that serves no agricultural purpose — it's for sitting +- Communal fire or gathering infrastructure between households if multi-family +- Doors and windows decorated with seasonal plantings (tells you the season) +- The exterior says "people live here and they'd welcome you" + +**Same farmland terrain palette. Completely different feel. Both immediately readable.** + +### 3.3 Is DLC the Right Model for This? + +Araminta defined the 5-6 terrain palettes in Round 2. The heritage grammar overlay I'm describing above is NOT a new template library — it's a modifier applied AT CHUNK FILL TIME to the existing terrain palette. It needs: + +1. Per-root organizational rules (the table above, encoded as modifier flags) +2. Heritage-tagged variant assets for decorative/organizational elements (fences, plantings, gathering spaces, signage) + +The base game MUST ship the heritage grammar overlay rules — they're part of the core cultural differentiation system. What DLC can expand: additional heritage-tagged variant assets for each terrain type (more variety in what "Vine farm aesthetic" looks like). But the grammar rules themselves are base game. + +**Qatux should note:** This is an implicit decision forming — Araminta's terrain palettes need to be specified not just as 5-6 palettes but as (terrain) × (heritage root grammar modifier). That's a cross-domain requirement that needs to be stated explicitly. + +--- + +## Section 4: Playstyle Affinity Per World Type + +Not every place serves every playstyle. This should be first-class generator knowledge — the generator needs to know what it's optimizing for when producing a given setting. + +### 4.1 The Affinity Matrix + +I'm defining affinity levels as: **Primary** (this playstyle is naturally strongest here), **Secondary** (playable with adjusted expectations), **Weak** (possible but requires design work), and **Poor** (structurally unsuitable). + +| Setting Type | Investigation | Tycoon | Dating Sim | Political Drama | Assassination | +|---|---|---|---|---|---| +| **Station transit hub** | Primary | Primary | Secondary | Secondary | Secondary | +| **Station administrative/corporate** | Secondary | Secondary | Weak | Primary | Primary | +| **Station industrial/freight** | Secondary | Primary | Weak | Secondary | Weak | +| **Station residential** | Secondary | Weak | Primary | Secondary | Weak | +| **Frontier/pioneer settlement** | Secondary | Secondary | Primary | Secondary | Secondary | +| **Agricultural town** | Secondary | Primary | Primary | Secondary | Poor | +| **Industrial extraction site** | Secondary | Primary | Weak | Secondary | Weak | +| **Maritime port** | Primary | Primary | Secondary | Secondary | Secondary | +| **Tourist resort** | Weak | Primary | Primary | Secondary | Secondary | +| **Military/security installation** | Secondary | Weak | Secondary | Primary | Primary | +| **Research outpost** | Primary | Weak | Secondary | Secondary | Primary | +| **Criminal nexus** | Primary | Secondary | Weak | Secondary | Primary | +| **Political capital** | Secondary | Secondary | Weak | Primary | Primary | +| **Ancient/heritage site** | Primary | Weak | Secondary | Secondary | Secondary | +| **Wilderness (no society)** | Weak | Secondary | Weak | Poor | Primary | + +### 4.2 What the Affinity Matrix Means for the Generator + +The significance tier (Center-stage → Insignificant) and the setting type determine the **playstyle content budget**. A military installation shouldn't try hard to generate dating sim content — it should focus its limited content budget on political drama and assassination affordances. + +**Implementation:** The `GuaranteeAuditResult` (Gestalt's 11-check struct) should include a `primary_playstyles: Vec<Playstyle>` field derived from setting type. Full-complexity districts guarantee all 7 archetypes + 4 additional. But the **density** of content per archetype is weighted toward the primary playstyles. + +The economic node is required for all Full districts — but in a military installation, the economic node looks like supply procurement, not a commercial market. In a tourist resort, the economic node is the booking desk, not a freight logistics hub. Same structural requirement, different content expression. + +### 4.3 Assassination-Specific Affinities + +Assassination playstyle has a unique affinity driver that others don't: **target value**. An assassination play doesn't make sense in a poor agricultural village (the target isn't worth the risk) unless that village is specifically flagged as harboring something important. + +The `network_position` parameter I defined in Round 2 (network-significant / regionally significant / locally significant / marginally located) correlates with target value: +- Network-significant settings have high-value targets worth assassination +- Locally-significant settings have targets whose assassination serves local rather than systemic goals +- Marginally located settings have essentially no viable assassination targets — unless a high-value target is visiting (which creates a special scenario type) + +This means: the `assassination_difficulty` descriptor I proposed in Section 1 should be accompanied by `assassination_target_density` — how many viable assassination targets exist in this setting. These together define whether assassination is a viable playstyle here. + +### 4.4 The "Poor" Rating + +A setting rated Poor for a playstyle should not ACTIVELY PREVENT that playstyle — it should just fail to support it well. A wilderness area rated Poor for Political Drama isn't impossible to play politically — it's just that the generator won't produce the spatial affordances that political play needs. A player who insists on playing politics in the wilderness will find sparse, unsatisfying affordances for it. + +This is a design choice: the generator serves the naturally suited playstyles, not all playstyles equally. Players who want deep political gameplay go to political capitals; players who want wilderness assassination go to wilderness. The world has specialization, which is realistic. + +--- + +## Section 5: "Insignificant" as Social Lens — Per Playstyle + +Carrying forward CR2-5 (Miri Round 2): insignificance is relational, not absolute. Now I need to show how the SAME backwater reads completely differently depending on what you're there to do. + +### 5.1 The Backwater Through Five Lenses + +**Setting:** A small agricultural settlement, ~150 people. Subsistence farming, one tavern that serves as community center, no faction presence, locally-significant only. Stone/Tide heritage blend. Community has been here 40 years. They know everyone who's ever passed through. + +**Through the investigator's lens:** + +*What's significant here:* Everything is visible. The grey economy, if present, is ONE person in ONE back room. When a crime happens, EVERYONE knows something about it. The investigator's challenge is not finding information — it's that the information knows about THEM. The community will follow their investigation with intense interest and discuss their methods openly. + +*The twist:* The insignificant backwater is where someone goes to HIDE. The most important information in the settlement might be: this person shouldn't be here. A network-significant actor gone to ground in a local-significance settlement. The backwater reads as insignificant — until the investigator notices that one resident has habits that don't fit the Stone/Tide cultural profile. + +*The gameplay:* Not "who committed the murder" but "why is this person here and what are they hiding from." Investigation inversion: the evidence isn't buried, it's too visible. The murder is small (one person). The implications are enormous. + +**Through the tycoon's lens:** + +*What's significant here:* Land rights. Water rights. Agricultural output that flows out through one trading route. Someone owns that route, and they're extracting from everyone who uses it. + +*The opportunity:* The agricultural settlement is CAPTIVE. They can't easily change suppliers or buyers because the infrastructure doesn't support alternatives. The tycoon who finds the single point of leverage (the route, the storage, the equipment) finds a monopoly opportunity. + +*The gameplay:* But the community knows everyone. The tycoon's economic maneuvers are completely visible. Making a quiet deal with the route-owner doesn't stay quiet for long. Economic play in a backwater is conducted entirely in public — which means the community has opinions about what you're doing. + +**Through the dating sim lens:** + +*What's significant here:* This community has 150 people and has had 40 years to develop rich relationship histories. The tavern has regulars who have known each other their entire adult lives. The social graph is dense, fully connected, and intensely aware. + +*The dynamics:* Dating sim in an insignificant place runs at a completely different register from a hub. There is no anonymity phase — you're known from day three. Every relationship you form is visible to everyone else. Third parties have opinions. Family/community approval is inescapable. The cultural heritage (Stone/Tide = community approval as social norm) amplifies this. + +*The gameplay:* The most private thing you can do is become a regular fast — be so present that your presence is unremarkable. The romantic play is not "meet and discover" but "earn belonging." This is a different game. Some players will find it more satisfying than hub romance precisely because the stakes are personal and socially embedded. + +**Through the political drama lens:** + +*What's significant here:* One person runs the community assembly. One trading route operator controls the economic access. Two extended families hold most of the land tenure. The political map is small enough to walk in ten minutes, but the entanglement is complete. + +*The drama:* No institutional mediation. No Commission to appeal to. Political conflict is immediate, personal, and has no legitimate outside arbitration. Political drama in the backwater is INTERPERSONAL in a way that hub political drama is not — you're dealing with the people directly affected by the decisions, not their institutional representatives. + +*The gameplay:* Political drama players in a backwater are playing coalition politics at human scale. Win over Aia's family, lose Torval's approval. This is simultaneously more emotionally intense and more mechanically tractable than hub politics — the actors are knowable. + +**Through the assassin's lens:** + +*What's significant here:* A locally-significant backwater has Poor assassination affinity by default. There are no high-value targets. The entire community knows every movement of every person. The post-act information environment is impossible to control. + +*The exception:* If someone has gone to ground here — someone network-significant hiding as a locally-insignificant person — then the backwater is the IDEAL assassination environment from the target's perspective and the WORST from the assassin's. The target has hidden in the optimal low-surveillance network location. The assassin must penetrate a high-community-awareness setting, work without being remembered, find the disguised target, act, and leave without triggering a community that will talk about the stranger who visited for three days right before someone died. + +*The gameplay:* Assassination in the backwater is the hardest assignment. The assassin who can do this cleanly is elite. The scenario type: "the target has gone to ground here. This world is locally-significant, Stone/Tide heritage, 150 people, everyone knows everyone. Good luck." + +### 5.2 The Generator Implication + +The same backwater world should be generated such that all five lenses can find their specific engagement — even though only 1-2 will be Primary affinity. What changes per lens is not the content but the FRAME through which the player approaches it. + +The generator needs to ensure that even a locally-significant, Moderate-complexity district has: +- At least one person whose presence is anomalous (investigation hook) +- At least one economic chokepoint that's exploitable (tycoon hook) +- At least one sustained social gathering with routine (dating sim hook) +- At least one contested allocation decision (political hook) +- At least one visitor or newcomer whose identity is uncertain (assassination hook, latent) + +None of these requires separate content — they can all be expressed through the same set of NPCs with appropriately complex profiles. The investigator's "anomalous person" is the same NPC the tycoon recognizes as having unusual resources, the political player sees as holding uncertain allegiance, and the assassin flags as possibly the target. + +**One NPC, five lenses.** That's the design target. + +--- + +## Section 6: Dynamic World Modification — Trauma Events + +When a gas explosion destroys part of a district, what changes culturally? This is the question that connects worldbuilding to dynamic simulation. + +### 6.1 The Trauma Event Framework + +A trauma event is a **historical event modifier applied to a living district** rather than to a pre-generated historical record. The architectural system for this already exists (Tyre's `EraModification` + Gestalt's `era_cause` field). What's needed is the **cultural aftermath model** — how does the society profile respond to acute stress? + +The key insight: **trauma reveals the society profile more clearly, not less.** A stressed community doesn't become a different culture — it becomes an intensified version of itself. The heritage roots that were latent become dominant. The trust mechanisms that worked passively become active. The absence parameters (what the community lacks institutionally) become critically felt. + +### 6.2 Trauma Types and Cultural Response + +**Type 1 — Physical Destruction (explosion, collapse, flood)** + +Phases: + +*Immediate (1-7 days):* +- Information flow SPIKES: everyone talks about what happened. For 72-96 hours, the normal information siloing is suspended. +- Community pattern shift: ANCHOR-type NPCs dominate (the people who hold the community together step forward). CATALYST NPCs (people in crisis) increase. HANDLER NPCs (operators) temporarily reduce activity. +- Access topology changes: blocked routes create new informal paths; rubble creates new informal zones. +- Faction response matters enormously: who responds first, how, and with whose resources — this shapes community trust for years. + +*Medium-term (1-8 weeks):* +- **Heritage root response:** + - **Frost**: community closes, rebuilds quietly, does not discuss the trauma publicly. Asks for practical help. Rejects offered emotional support as intrusive. Suspicion of outsiders increases. + - **Tide**: community gathers, processes grief publicly, creates ritual around the event. A community mourning becomes a social institution. Outsider sympathy is welcomed. + - **Iron**: collective response — mutual aid organized, demands for accountability raised, solidarity demonstrated through shared labor. The community investigates who is responsible. + - **Dust**: all hands. Every community member contributes. The social hierarchy flattens in crisis. Leadership goes to the most capable, not the most credentialed. + - **Vine**: the social fabric IS the response — meals cooked, children cared for, emotional support structured through existing relationships. The community grieves as a social entity. + - **Arc**: documentation, inquiry, accountability. The community produces a record. Someone is writing down what happened and why. + +*Long-term (months to years):* +- Era modification is logged with `StructuralDestruction` event type +- Drift stage may increase in affected area (forced evolution, reduced cosmopolitan blending as community turns inward) +- Some NPCs leave (the destruction is the tipping point for people who were already on the margin) +- Memorial markers appear (heritage-root-dependent form) +- Trust recovery curve: the community's trust of institutions (Commission response quality) either rebuilds or permanently decays + +### 6.3 The Society Profile Under Stress + +**What changes:** +- `trust.building_rate` for STRANGERS decreases (community turns inward) +- `insider_trust_threshold` decreases (insiders become more trusted, not less — reciprocal tightening) +- `grey_economy` activity shifts: some operators become more visible (mutual aid operates outside formal channels), some go dark +- `faction_presence` operational character may shift: Commission may have formal authority but reduced actual cooperation + +**What does NOT change:** +- Heritage root identity (this is who we are — it doesn't change under stress, it intensifies) +- Economic function (people still need to work) +- Settlement motivation (why we're here doesn't change because something broke) + +### 6.4 Trauma as Gameplay Driver + +For each playstyle, a trauma event creates EXCEPTIONAL CONDITIONS: + +**Investigation:** The immediate information spike is a window. For 72 hours, people will talk who wouldn't normally. The community's defenses are down. Evidence that's normally buried becomes surface-level visible. Counter: everyone is also watching the investigator more closely. + +**Tycoon:** Economic disruption creates opportunity gaps. Suppliers for reconstruction materials are needed immediately. The economic void left by destroyed infrastructure is a market opening. Counter: predatory behavior during community tragedy is visible and remembered. + +**Dating sim:** Crisis creates intimacy. Shared trauma is a bonding accelerant. Counter: crisis reveals character — both the player's and the NPCs'. The Frost person who retreats inward during trauma requires a completely different response than the Tide person who needs to process publicly. Reading the heritage root correctly under pressure is a dating sim skill check. + +**Political drama:** The aftermath is a power redistribution event. Who controlled the destroyed infrastructure? Who controls the reconstruction? Who is blamed? Political drama in the aftermath is about exploiting the vacuum or managing the accountability before it resolves against you. + +**Assassination:** Trauma events create TWO opportunity windows: +1. The immediate chaos window: high community distraction, institutional focus elsewhere, normal patterns suspended +2. The reconstruction vulnerability window: the target may be physically present at the damaged site (overseeing reconstruction, inspecting, attending memorial), reducing normal protection +Counter: institutional presence may be ELEVATED during reconstruction, and community attention is heightened. + +### 6.5 The Generator Implementation + +**What the generator needs to support:** + +1. **Trauma events as historical modifiers** (already exists via `EraModification`) + - Add `ModificationType::TraumaEvent` with subtypes: PhysicalDestruction, EconomicDisruption, PoliticalShock, ViolenceEvent, MigrationShock + - Carry `cultural_aftermath: HeritageRootResponse` — the specific community response derived from dominant heritage root + +2. **Active modification state** for living worlds (distinct from historical record) + - The historical record stores what happened; the active modification state stores what's currently different from baseline + - Active modification state decays over time (trauma aftermath is temporary — society returns toward baseline) + - Rate of return is heritage-root-dependent (Frost: faster private recovery, slower institutional normalization; Tide: faster social recovery; Arc: never returns to pre-inquiry-completion state) + +3. **NPC pattern weight modification** for affected areas + - NPC generation in post-trauma areas should weight ANCHOR, WITNESS, REMNANT patterns higher; CATALYST and NOBODY patterns differently + - This is the mechanism that makes post-trauma areas FEEL different — the people in them behave differently + +4. **Access topology update** + - Blocked routes from structural damage create new informal paths (these become the post-trauma informal zone) + - The generator should flag destroyed social sites as `inactive` with an optional `temporary_replacement` pointer + +### 6.6 What I'm Not Solving Here + +Dynamic world modification that happens DURING a playthrough (not historical pre-generation) is a simulation concern more than a worldbuilding concern. Tyre will address whether the server's simulation tick system can handle live trauma events modifying the `PreparedDistrict` or whether these require regeneration. + +My contribution is the **cultural response layer** — the worldbuilding logic that determines what a trauma event MEANS for how a community behaves. The simulation system implements the mechanics; the society profile provides the parameters for how those mechanics are culturally expressed. + +--- + +## Summary: Round 3 Contributions + +**1. Assassination as the sixth playstyle** — fully integrated into the society profile. Cultural ingredients drive three variables: observational density (how visible you are), information liquidity (how knowable the target is), and aftermath engagement (how hard they look afterward). The combination defines `assassination_difficulty` as a derived profile descriptor. + +**2. Non-urban informal zones** — redefined from "outside institutional surveillance" to "outside community social field." Each terrain type has specific informal zone equivalents; heritage roots determine which type appears. The generator tag is three-way: `social_permission` / `physical_distance` / `utilitarian_cover`. + +**3. Palette granularity** — two-layer model: terrain base palette (material vocabulary) + heritage grammar overlay (organizational principle). This is a base game requirement, not DLC. All ten heritage roots have distinct organizational grammar rules applicable to any terrain type. DLC expands variant assets; the grammar rules are core. + +**4. Playstyle affinity matrix** — full matrix across 15 setting types and 5 playstyles. Generator knows what it's optimizing for. Poor-affinity playstyles are not blocked but are not budgeted for. One NPC with sufficiently complex profile can provide entry hooks for all five lenses simultaneously. + +**5. Insignificance through five lenses** — same backwater world, five different engagements. Investigation: the anomalous person; Tycoon: the exploitable chokepoint; Dating sim: the belonging-earning; Political drama: personal-scale coalition politics; Assassination: the hardest assignment. Minimum content for even Moderate-complexity insignificant districts: one anomalous presence, one economic chokepoint, one social gathering rhythm, one contested allocation, one visitor of uncertain identity. + +**6. Dynamic world modification** — trauma events as living modifications applied to society profiles. Six trauma subtypes. Response is heritage-root-determined (intensification of existing cultural character, not transformation). Implementation requires `TraumaEvent` modification type, `cultural_aftermath` field with heritage response, NPC pattern weight modification for affected areas, and active modification state with decay rate. + +--- + +**Author:** Miri +**Date:** 2026-02-27 +**Status:** Round 3 complete. + +**Questions for Round 4 or implementation:** +- Tyre: The active modification state (for living trauma events) — is this a second layer above `PreparedDistrict`, or does it modify the prepared district in place? Decay rates need to map to the simulation tick. +- Gestalt: The `assassination_difficulty` descriptor — should this be surfaced in the `GuaranteeAuditResult`, or is it a separate field on `DistrictSkeleton` alongside the playstyle affinity vector? +- Araminta: Heritage grammar overlay rules (the ten-row table in Section 3.1) need to be encoded as modifier flags that chunk fill can read. What's the right representation — per-heritage modifier objects that chunk fill applies, or lookup tables within the terrain palette assets? +- Nigel: The "one NPC, five lenses" requirement (Section 5.2) — does the NPC's 10-axis model already support this, or does providing five-playstyle hook simultaneously require additional content axes? diff --git a/docs/workshops/generator-architecture/miri-round4.md b/docs/workshops/generator-architecture/miri-round4.md new file mode 100644 index 000000000..3fdc85348 --- /dev/null +++ b/docs/workshops/generator-architecture/miri-round4.md @@ -0,0 +1,595 @@ +# Generator Architecture Workshop — Round 4: Miri (Worldbuilder) + +**Topic:** Write the NPC. Close the open questions. Finalize vessel grammar. +**Date:** 2026-02-27 +**Lead decisions acknowledged:** WorldTier wins over SignificanceTier. DramaDensity is runtime state (not on DistrictSkeleton). Entity-carried MobileChunk is CORE. + +--- + +## Preamble + +The lead said: write the NPC. Don't discuss whether it could theoretically work. Show the work. + +This is the work. + +--- + +## OQ-R4-E: One Character. One Settlement. Five Lenses. + +### The Setting + +**Harrow Drift** — a farming settlement, 40 years established, ~150 permanent residents. + +WorldTier: Backwater. ComplexityTier: Moderate. DramaDensity: Zero (currently). + +Heritage: Stone (0.6) + Tide (0.4). Drift stage: crystallizing (40 years is young for Stone, which wants centuries). +Settlement motivation: economic-agricultural — founded by a cohort of families who wanted land tenure they couldn't get in a Commission-managed hub. +Economic function: subsistence agriculture + modest surplus trade via one seasonal route. +Economic pressure: generational-extraction (the land titles are real but the route operator takes 18% of surplus trade). +Faction presence: Commission absent; no Syndic presence; local governance = informal assembly (five founding family heads + elected coordinator). +Meridian coverage: none. + +**Community character:** Stone culture means the founding families have territorial memory and protective silence. Tide culture means people eat together, celebrate together, and a stranger is immediately noticed — and discussed. The combination: tight community with a warm surface and a hard interior. You're welcomed at the table on day one. You're trusted at year ten, if you've earned it. + +Forty years in, they know every family secret. Including who belongs and who doesn't quite fit. + +--- + +### The NPC + +**Ysabel Vorn** +Apparent age: mid-40s. Arrived at Harrow Drift 14 years ago, nominally as a partner of a farmer who left 8 years ago. The farmer left. She stayed. She runs water management. + +--- + +#### Full 10-Axis Profile + +**Axis 1 — Behavioral Pattern (Social Archetype)** + +Primary: ANCHOR. Ysabel is one of the five or six people the community would name if asked "who keeps this place running." Her water management role is structurally critical (every farm's viability depends on fair allocation during dry months). She shows up consistently, mediates disputes without taking sides, and participates in community labor beyond her direct responsibilities. + +Secondary: REMNANT. This is the layer that only long observation reveals. She is holding on — not to the past she claims, but to a past she won't name. Something about her patterns suggests someone who has been running and has decided, tentatively, to stop here. + +**Axis 2 — Surface Motivation (Publicly Visible Goal)** + +Keep the water system equitable. Prevent the Fennen family from leveraging their founding-family status into preferential allocation. Maintain the settlement's social cohesion through the one resource that everyone needs and no one can leave without. + +This motivation is REAL. It is not a cover story. She has spent 14 years genuinely trying to be useful here. + +**Axis 3 — Actual Motivation (What She Actually Wants)** + +Stay hidden. Safe. Not found. + +The equitable administration serves the actual motivation: if she is indispensable and trusted, no one asks questions about her origin. A community that needs you doesn't scrutinize you. She has been performing trustworthiness with strategic precision for 14 years, and by now most of it has become genuine — she actually cares about Harrow Drift. But the original reason she chose to care this much was survival. + +Secondary actual motivation: she is watching for Kael Voss. The man who was displaced by the fraud she documented. She's known for three years he lives 40 km east. She hasn't approached him. She tells herself this is because contact would expose her. The truth is more complicated. + +**Axis 4 — Vulnerability/Secret** + +Seven years before she arrived at Harrow Drift, Ysabel was a Commission data analyst specializing in land-grant records — a mid-level position that gave her access to the historical title database across a significant region. + +During a routine audit, she found it: a fabricated land-grant record that had been inserted into the Commission database, dated 22 years prior, displacing a pre-existing title held by the Voss family. The fabrication was clean enough to pass cursory review. It wasn't clean enough to pass her review. She traced it to a Syndic subsidiary acting on behalf of an executive named Pehr Callen, who had needed the land for a private extraction operation. The Voss family — Kael's parents, then — had been compensated under a false legal premise and relocated. + +She made a copy of the file chain. Then she made a mistake: she contacted a ring-adjacent information broker, thinking she could pass the evidence to someone who would use it without exposing her. The broker took the files and disappeared. Three weeks later, a Commission warrant was issued for a data analyst who had accessed restricted historical records without authorization. Her name. + +She ran. She has been running in a single direction (toward places with no Meridian coverage and no Commission presence) for seven years before arriving at Harrow Drift. The warrant is real. The data theft charge is real. The underlying evidence that motivated the theft is also real. + +She doesn't know if the copy she passed to the broker ever reached anyone. She doesn't know if Pehr Callen knows she's alive. + +**Axis 5 — Information Access (What She Knows)** + +Tier 3 (complete): Harrow Drift's water system, seasonal allocation records, every family's land and water claims going back to founding. + +Tier 3 (complete): The interpersonal relationships, grudges, debts, and loyalties of every person in the settlement. Fourteen years of observation. She is the settlement's institutional memory despite being a latecomer. + +Tier 2 (partial, aging): The Commission land-grant system's structure and failure modes. She has been away from Commission data infrastructure for 14 years, but the analytical framework is intact. She can read a land title and identify if something is wrong. She knows this region's historical land grant database well enough to identify additional fabrications if they exist. + +Tier 3 (specific): The Pehr Callen conspiracy file chain. She has a memory copy — she memorized the key document numbers and dates before she ran. She does not have the physical files. But she can reconstruct enough to make an investigator's or legal advocate's job tractable if given access to the right archive. + +Tier 1 (basic): Kael Voss exists, lives 40 km east in a settlement called Vermin's Cross, farms barley. She knows his name and location but nothing about his current life. + +**Axis 6 — Trust Architecture** + +Heritage trust model: Stone (tenure-based, very slow, deep once earned) + Tide (public demonstration, community participation). This is her actual operating model, not a calculated performance — she has absorbed the community's trust norms over 14 years. + +Trusted (deeply): three people. Opal Dun (Cara's grandmother, 70s, has never asked about Ysabel's past and shows by this that she has noticed there's something to not ask about). Lev Fennen (Orik's youngest son, who disagrees with his family's political ambitions; Ysabel has quietly protected his dissent from family pressure). One other who is not significant to this document. + +Trusted (functionally): the approximately 40 people who interact with her regularly through water management and community events. She is warm with them. She is not open. + +Cautious (everyone else): 110 people she is friendly toward and emotionally reserved with. + +Zero trust: strangers. A new arrival triggers her internal threat assessment immediately. She remains warm and welcoming on the surface — this is Stone/Tide culture. Internally, she is reading every detail for signs of Commission connection or Syndic interest. + +Trust-building rate for a player character: slow by default (Stone tenure model). Accelerates via public contributions (Tide model) — help during the water dispute, participate in harvest labor, accept an invitation to a community meal and behave well. Decelerates immediately if the player shows interest in her history. + +**Axis 7 — Routine Pattern (Movement and Schedule)** + +*Dawn:* Solo inspection of the main water channels and reservoir (45 minutes, predictable path, starts at the sluice gate near the east field boundary and ends at the primary storage tank north of the settlement). This is her most private daily interval. + +*Morning:* Available at the water management building (small structure, central location) for allocation queries. Frequent foot traffic. Social but businesslike. + +*Midday:* Eats at the community gathering space with whoever is present. She ensures this visibility consistently — this is both Stone/Tide cultural participation and deliberate cover maintenance. + +*Afternoon:* Variable. Field work with neighbors (she contributes labor across farms, building distributed goodwill). Or: paperwork (the settlement's water records, which she maintains meticulously). Or: if a dispute is active, she meets with involved parties privately. + +*Evening:* Selective community gathering attendance. She is present often enough that absence is unremarkable. She does not attend every event — which prevents over-exposure. + +*Weekly:* Attends every community assembly. Sits in the middle third of seating (neither front-row authority nor back-row disengagement). Speaks rarely, but when she speaks, the room listens. + +*Seasonal:* Pre-harvest water allocation period (approximately six weeks before harvest) is her highest-activity, highest-visibility period. She is present, decisive, and politically exposed during this time. Orik Fennen challenges her allocation decisions every year during this period. She navigates it. The community watches. + +*Anomaly in routine:* Once every six to eight weeks, she makes a solo trip to the property boundary — a walk that takes her approximately 90 minutes and that she does not explain to anyone. No one has asked. She is watching the trade route approach. + +**Axis 8 — Economic Position** + +Direct control: water allocation for every farm in the settlement during the 10–12 week dry-season period. Without her management, the dry season produces disputes that the community's informal governance cannot resolve. She has not monetized this leverage. She is ideologically opposed to doing so — it would make her someone who exploits the community, which contradicts her actual care for it. + +Indirect leverage: the Commission land-grant knowledge she carries. This is not an active economic asset — she cannot sell it without exposing herself. But: it IS the settlement's most valuable economic intelligence asset if anyone knew she had it. Several land claims in this region may rest on false foundations. If the fraudulent land-grant system extends beyond the Voss case (she suspects it does), then whoever controls access to that information controls significant economic leverage across the region. + +Personal economics: modest. She takes no payment for water management (community expectation: the role is a community service). She participates in the community's labor exchange economy. She has small savings, no investment claims, no land title (the farm where she arrived is now owned by a different family). + +**Axis 9 — Relationship Network (Triangle Memberships)** + +*Triangle 1 — Social/Political (Active):* +Ysabel (arbiter) ↔ Orik Fennen (senior landholding farmer, Founding Family, believes water allocation should be weighted by land area owned) ↔ Cara Dun (young farmer, third generation, believes allocation should be equal per-household regardless of land size). Purpose: Political + Social. + +Ysabel is the central node. Both parties trust her differently: Orik respects her competence and assumes she's managing the situation toward a status quo that serves everyone; Cara trusts her because she's seen Ysabel resist Orik's pressure. The tension is real, the allocation decision is real, and this triangle activates during every dry-season period. One year it will break and Ysabel's role will be at stake. + +*Triangle 2 — Investigation/Economic (Latent):* +Ysabel (holder of evidence) ↔ Pehr Callen (Commission-adjacent Syndic executive, the original conspirator) ↔ Kael Voss (displaced victim, 40 km east). Purpose: Investigation + Economic. + +This triangle is inactive because none of the three nodes knows the other two are in proximity. Kael doesn't know Ysabel exists or has evidence about his family's displacement. Pehr Callen doesn't know Ysabel is alive or where she is. Ysabel knows about both of the others and has chosen not to move. + +The triangle ACTIVATES if: an investigator finds any thread connecting Ysabel to Commission records, or Kael to the land dispute, or Pehr Callen's name surfaces in any adjacent investigation. It also activates if Ysabel crosses her own tolerance threshold and decides to act. + +*Triangle 3 — Tactical (Latent):* +Ysabel (target) ↔ Pehr Callen's agents (potential protector/executor) ↔ any investigator or player character who learns Ysabel's significance. Purpose: Tactical + Investigation. + +This triangle exists only from the perspective of someone who knows Ysabel is network-significant. From inside the settlement, Ysabel is an ANCHOR with no visible enemies. The Tactical triangle is invisible until activated by external knowledge. + +**Axis 10 — Tolerance Threshold** + +*For community conflict:* Very high. She has managed 14 years of community disputes without breaking character. She can absorb extended social friction, political challenge, and personal criticism without acting precipitously. + +*For exposure:* Near-zero. If she believes she has been found — by Commission, by Pehr Callen's people, by anyone with hostile intent toward her history — she will leave. She has a pre-prepared exit: she knows which route out of Harrow Drift is fastest, where the first settlement is that she could pass through without being remembered, and what a cover identity looks like. She has not used this exit in 14 years and has added more reasons not to use it each year she stays. But the exit exists. + +*For Kael Voss:* Declining. She is aware her threshold is lowering. Three years ago she accepted she knew where he was. Each passing season she is slightly more aware that the evidence she carries is not helping him while she holds it. She does not know what will push her to act. She suspects something visible — witnessing his situation deteriorate, or meeting him accidentally — would be enough. She avoids the road to Vermin's Cross. + +*For the player character:* Variable based on what they represent. A player who seems to be passing through with no investigative intent gets the warm Stone/Tide welcome and gradually earns trust through community participation. A player who asks specific questions about Commission presence, land records, or her history will find her warmth becomes cautious-correct very quickly. She is not hostile. She is self-preserving. + +--- + +### Five Lenses, One NPC + +**The Investigation Lens** + +What the investigator sees at first: a competent, trusted community administrator. No obvious irregularities. Well-liked, stable, clearly not leaving. + +What the investigator eventually sees: the void. Ysabel has no history before arriving at Harrow Drift. No family mentioned (the farmer she arrived with is gone and she doesn't speak of him). No home system. When asked directly, she gives a soft non-answer: "I needed somewhere different." Her knowledge of Commission administrative systems — visible in small details, the specific language she uses about land claims, her taxonomic approach to water allocation records — is more than informal. She has been trained. + +The investigation hook: she is the anomalous presence. Not because she committed a crime here. Because she shouldn't be here at all — someone with her knowledge and capability would not end up in a Backwater/Moderate settlement unless they chose it for reasons that aren't the stated ones. + +The investigation play: not "find the murderer" but "find out who she's hiding from and why." The answer leads off-world — to a Commission warrant, to Pehr Callen, to Kael Voss 40 km east, and to evidence of a land-grant fraud that is not local in scope. + +The information ladder specific to investigation mode: +- Low tier: "She doesn't talk about where she came from." (Any community member will say this within a day of asking.) +- Mid tier: "Her water management records use Commission administrative notation." (Visible if you look at her actual paperwork.) +- High tier: "She made that trip to the property boundary again — she goes every six to eight weeks, alone, and looks down the trade route." (Requires sustained observation or an insider who's noticed.) +- Insider tier: Opal Dun says: "I stopped asking seven years ago. Whatever she's carrying, she's carried it longer than she's been here." (Unlocked via deep trust with Opal.) + +**The Tycoon Lens** + +What the tycoon sees: the economic chokepoint. Water allocation controller. Every farmer in the settlement is economically dependent on the seasonal allocation decisions she makes. + +The opportunity: she's not exploiting this. This is immediately unusual to a tycoon sensibility. Someone controlling a mandatory resource in a captive market and not charging above-market rates for it is either naive or principled. In this case: principled. But principled people can be influenced — you just need to find the right currency. + +The tycoon's possible moves: +- Offer her a cut of the trade route operation (rejected — she doesn't want money; money creates visibility) +- Help formalize the water allocation as a legal structure that protects her role from community vote challenge (interesting to her — reduces her political vulnerability) +- Offer access to a secure communication channel outside the settlement (very interesting — she wants to know if the Commission warrant is still active after 14 years) +- Offer information about Kael Voss (extremely interesting — this is the tycoon accidentally holding the key) + +The deeper tycoon layer: she holds the evidence of a fraudulent land-grant that likely extends across the region. If the tycoon discovers this (requires significant trust-building or investigation), they have access to an information asset that could: a) be used to challenge existing land titles (disrupting established economic interests), b) be sold to parties who want to restore original titles (Kael Voss, for instance, or his legal advocates), or c) be used as leverage against Pehr Callen (corporate coercion material of significant value). The water management role is the tycoon's entry point. The Callen file chain is the real prize. + +**The Dating Sim Lens** + +What the dating sim player sees: someone who is clearly trusted and clearly closed. The warm Tide exterior is real — she participates in community life, she brings food to gatherings, she knows everyone's name and their children's names. But get her alone and there's a quality of careful control to her openness. She listens more than she speaks. She deflects personal history questions without making you feel deflected. + +The arc: she is not unwilling to connect — she is afraid it's not safe to. The dating sim challenge is not reading her correctly. It's creating enough perceived safety that she stops performing and starts actually trusting. + +Heritage-appropriate trust signals (what earns her): +- Participating in community labor without being asked (Tide: public demonstration) +- Showing up consistently over time (Stone: tenure earns trust) +- Accepting an invitation to a community meal and not asking about her past (both heritages: respecting the social norm) +- Helping during the water dispute without trying to position yourself politically (demonstrates you're not there to exploit the community's vulnerabilities) + +The milestone: she lets something slip. A city name she shouldn't know — a reference point for a Commission administrative district that a person who "needed somewhere different" wouldn't have. The player catches it. The choice: press (which opens her up, partially), or let it go (which deepens her trust further). Pressing too hard too early gets the careful-correct Ysabel. Giving her room gets the real one. + +The actual relationship arc: she's been managing her isolation for 14 years. She is genuinely lonely. She has not allowed herself to be seen. A player who gives her consistent, patient, non-invasive attention is offering something she hasn't been able to have for a very long time. When she does open, it's in pieces — not a confession, but a gradual admission that she exists more than she's been letting on. + +The final vulnerable act: she takes the player to the property boundary and watches the trade route without explaining why. This is the closest she gets to showing her actual situation. It's not an explanation. It's a gesture of trust. + +**The Political Drama Lens** + +What the political player sees: the most powerful person in the settlement by functional leverage, who is self-deliberately not using that power for political gain. This is a vacuum that the political game will fill one way or another. + +The active political conflict: Orik Fennen's faction (Founding Family entitlement, believes resource allocation should reflect historical investment) vs. Cara Dun's faction (generational equity, believes resources belong to the current community equally). Ysabel is the tiebreaker. Orik knows this and applies steady social pressure on the allocation decisions. Cara knows this and counts on Ysabel's stated commitment to equity. + +The political play space: +- Help Ysabel maintain her role through the community assembly (requires building enough coalition that Orik can't replace her with a sympathetic appointment) +- Exploit the conflict by backing one faction (either gets you a powerful local ally but costs you the other half of the settlement) +- Use Ysabel's role as leverage to change the allocation rules permanently (requires her cooperation, which requires earning her trust) +- Discover that the Fennen family's founding-family entitlement has a land title anomaly in the regional records (Ysabel knows this; she's never said it; she's been protecting community stability by not introducing a land-title challenge into the political mix) + +The deep political layer: the land-grant fraud Ysabel documented wasn't just about the Voss displacement. If she's right that the fraud system was broader, several founding-family land titles in this settlement may also rest on Commission records that were manipulated to favor specific families over others. The political implications are settlement-destabilizing. She has chosen community stability over truth. A political drama player who uncovers this faces the same choice. + +**The Assassination Lens** + +Default assessment of Harrow Drift: locally-significant target pool only. No Commission presence, no Syndic presence, no network-visible actors. Standard assassination play = Poor affinity. Pass through. + +But. + +A player with access to higher-tier intelligence would find: there is a Commission warrant — 14 years old but still active — for a data analyst who accessed restricted land-grant records without authorization and then vanished. The last confirmed sighting was a system away, 14 years ago. The warrant was filed by the Commission. The underlying pressure came from Pehr Callen's Syndic subsidiary. + +Pehr Callen still exists. He is now significantly more powerful. The land-grant fraud is still formally concealed. The analyst who found it is still, technically, evidence of a crime — not because she committed one in any meaningful sense, but because she can testify to what she saw and what documents were altered. Callen has reason to want her permanently unavailable. + +This turns the assassination lens from "no viable targets" to "the highest-difficulty target in the region." + +The assignment (if received): locate and permanently silence a former Commission data analyst, current alias Ysabel Vorn, position water management coordinator, Harrow Drift settlement, population 150, no Commission presence. Mandate: no visible cause of death, no community suspicion, no connection to this contract. + +Operational difficulty: Extreme. + +The factors: +- *Community awareness*: Stone/Tide community, 150 people, every face known. A stranger spending more than two days triggers social comment. An assassin needs a cover identity that gives them a reason to be here for long enough to establish a pattern. +- *Target observability*: Ysabel has a predictable dawn routine (the solo water inspection) — this is her only extended private interval. It's the obvious window. It's also the only time she is genuinely alone, which means any deviation from her solo status is immediately suspicious. +- *Pattern rigidity*: Stone heritage = high pattern rigidity. Her routine is reliable. The assassination window is identifiable. But: she is watching for this. Her 90-minute property boundary checks, her wariness with strangers, her pre-prepared exit — she has been expecting someone to come for 14 years. She will notice surveillance. +- *Aftermath*: Tide heritage means communal justice demand if she dies under suspicious circumstances. 150 people who loved her. They will talk. They will remember the stranger. They will ask questions that the Commission eventually hears about. "What happened to Ysabel?" is a question that could, if answered by the right investigator, reopen the original warrant investigation — and lead to Pehr Callen. +- *The operational paradox*: Silencing her to prevent her from testifying about Callen requires an operation clean enough that it doesn't spark the investigation that would reach Callen anyway. + +The assassination play is the hardest version of the hardest assignment: an elite target who has been hiding from exactly this for 14 years, embedded in a tight community that will investigate her death with personal intensity, in a settlement with no Meridian coverage (which means no tracking but also no controlled information environment). The assassin who does this cleanly is exceptional. + +--- + +### Does the 10-Axis Model Cover All Five Hooks? + +**Verdict: 4.5 of 5. One gap found.** + +The investigation hook lives in Axes 4 (Secret) + 5 (Information Access). +The tycoon hook lives in Axes 8 (Economic Position) + 3 (Actual Motivation). +The dating sim hook lives in Axes 6 (Trust Architecture) + 10 (Tolerance Threshold). +The political hook lives in Axes 9 (Relationship Network) + 2 (Surface Motivation). +The assassination hook lives in Axes 7 (Routine Pattern) + 4 (Vulnerability). + +**The gap:** None of the 10 axes explicitly encodes *network-level significance vs. locally-perceived significance*. Ysabel is locally-perceived as an ANCHOR with no enemies — she generates zero `assassination_difficulty` signal from the local society profile alone. The assassination hook only becomes available when a player has access to network-level intelligence (a Commission warrant database, Syndic contractor records, or specific investigation threads that trace back to Callen). + +The 10-axis model as currently specified cannot distinguish between: +- An NPC who is genuinely locally insignificant (no network-relevant secrets) +- An NPC who is locally insignificant in appearance but carries network-significant information or is network-relevant to external actors + +**Proposed extension: Axis 11 — Network Footprint** + +``` +network_footprint: Option<NetworkFootprintTag> +``` + +For most NPCs: `None`. They are exactly what they appear to be in the local context. + +For NPCs like Ysabel: `Some(NetworkFootprintTag)`, which records: +- The external actor(s) who consider this NPC significant +- The reason (possesses evidence, was witness to event, holds a capability, has a connection) +- The access tier required to see this footprint (investigation players reach it via Commission databases; tycoon players reach it via Syndic network intelligence; other playstyles may not reach it at all) + +This axis does not change local behavior. Ysabel is still an ANCHOR. Her routine is still the same. But the generator can now guarantee that the Tactical triangle (Ysabel ↔ Callen's agents ↔ investigating player) is instantiated, even when the district's local `assassination_difficulty` score would not flag it. + +**Implementation note:** The `network_footprint` field should be authored on specific NPCs and not procedurally generated. Procedural NPCs default to `None`. The "false backwater" scenario — where a network-significant actor is hiding in a locally-insignificant setting — is a named scenario type authored by content designers, not a generator output. The generator needs to *support* this scenario type (by providing the field and the Tactical triangle infrastructure), not generate it from scratch. + +--- + +## OQ-R4-C: Assassination Difficulty Descriptor — Definitive Placement + +**Answer: DerivedDistrictAnalysis, stored on DistrictSkeleton, computed at Phase 1 (NPC Population stage), not runtime-mutable.** + +The question was: does `assassination_difficulty` live on the DistrictSkeleton, on the SocietyProfile, or is it computed on demand? + +It should NOT live on the SocietyProfile directly — the SocietyProfile describes the cultural parameters, not their derived game-mechanical implications. The SocietyProfile does not know it's being used for game generation. + +It should NOT be computed on demand by the assassination gameplay system — it's used by the Tactical triangle instantiation logic during Phase 1, before the gameplay system ever runs. Computing it late means the Phase 1 skeleton doesn't know whether to instantiate Tactical triangles, produce egress multiplicity guarantees, or budget temporal opacity windows. + +It should NOT be on DramaDensity (runtime storyteller state) — the cultural difficulty of assassination doesn't change because the storyteller elevated the drama. A Dust-dominant community is still a Dust-dominant community regardless of how much drama is currently firing. + +**Canonical placement:** + +```rust +struct DistrictSkeleton { + // ... existing fields ... + guarantee_audit: GuaranteeAuditResult, // existing + derived_analysis: DerivedDistrictAnalysis, // NEW: computed from society profile +} + +struct DerivedDistrictAnalysis { + assassination_difficulty: AssassinationDifficulty, + // Computed from: observation_density × information_liquidity × aftermath_engagement + // All three derived from society_profile.heritage blend + economic_pressure + + assassination_target_density: u8, + // Count of NPCs with network_footprint: Some(_) OR with local significance that makes + // them viable local targets. Drives Tactical triangle instantiation budget. + + primary_playstyles: [AffinityLevel; 5], + // Per-playstyle rating derived from setting type. Drives content budget weighting. +} + +enum AssassinationDifficulty { Low, Medium, High, Extreme } +``` + +**What "computed from society profile" means concretely:** + +``` +observation_density = heritage_weighted(frost:low, tide:high, iron:medium_high, + dust:very_high, stone:medium, arc:low, vine:high, salt:low, + jade:high, spice:high_within_group) + +information_liquidity = heritage_weighted(frost:very_low, tide:high, iron:high_within, + dust:high, stone:low, arc:medium, vine:very_high, salt:conditional, + jade:low, spice:low_to_outsiders) + +aftermath_engagement = heritage_weighted(frost:low, tide:high, iron:high, dust:high, + stone:medium, arc:very_high, vine:medium_high, salt:medium, + jade:medium, spice:very_high) + +assassination_difficulty = classify( + observation_density × 0.35 + + information_liquidity × 0.30 + + aftermath_engagement × 0.35 +) +``` + +Faction presence modifies the computed value: Commission comprehensive coverage shifts difficulty up one tier (institutional investigation adds aftermath risk). Commission absent with high-Dust community culture = the most dangerous community aftermath without institutional support. + +**Used by:** Tactical triangle instantiation (decides how many Tactical triangles to budget per district, weighted by whether the difficulty makes assassination plausible gameplay). Spatial guarantee audit (Tier 3 conditional checks A-1 through A-4 fire for districts where `assassination_difficulty != Extreme` — Extreme difficulty districts don't need to budget for assassin success, they budget for assassin failure learning). Content balance tools (designer visibility into why a given district is Hard vs. Easy for assassination play). + +--- + +## OQ-R4-D: Heritage Grammar Overlay — Concrete Representation for Chunk Fill + +**Answer: Per-heritage HeritageGrammarOverlay structs stored in the generator's authored data, injected into ZonePalette at chunk fill time via weighted blending.** + +The 10-row heritage grammar table from my Round 3 document needs a form chunk fill can consume. Araminta's question was: per-heritage modifier objects, or lookup tables within terrain palette assets? + +Per-heritage modifier objects. The distinction matters for authoring workflow: lookup tables within terrain assets means the heritage grammar is embedded in art assets (Araminta's domain, not mine). Per-heritage modifier objects means the grammar rules are authored in worldbuilding data and consumed by chunk fill as parameters. This is the correct separation — I author the cultural grammar; the terrain palette assets provide the visual vocabulary; chunk fill applies the grammar to the vocabulary. + +**The concrete struct:** + +```rust +struct HeritageGrammarOverlay { + // For which root this applies + heritage_root: HeritageRoot, + + // BLOCK-LEVEL ORGANIZATIONAL PRINCIPLES + // These affect block planning decisions, not just chunk fill + + boundary_character: BoundaryCharacter, + // What physical form boundaries between properties take + // Frost: MinimalFunctional (wire/stake, minimal material) + // Stone: PermanentMaterial (stone wall, heavy posts) + // Tide: OpenOrNominal (cleared path, no physical barrier) + // Iron: CollectivelyMaintained (shared fence line, communally repaired) + // Dust: PracticalRepaired (patched material, visibly maintained because replacement is costly) + // Vine: OrganicIntegrated (planted hedge, climbing plants as boundary) + // Salt: ClearlyDemarcated (clean lines, legible entry points) + // Arc: LabeledAndCategorized (marked with notation, documented) + // Jade: AestheticlySelected (materials chosen for visual quality) + // Spice: HonorAdjacentEnclosure (compound wall, family territory marker) + + open_space_character: OpenSpaceCharacter, + // What purpose the open/unclaimed space between structures serves + // Functional / Gathering / Ornamental / Buffer / None + + structure_spacing: f32, + // Multiplier on base terrain spacing. 0.8 = compact, 1.0 = standard, 1.5 = dispersed + + // CHUNK-FILL LEVEL VISUAL MODIFIERS + // These affect which objects from the terrain palette get selected + + decorative_density: f32, + // 0.0 (Frost: no decorative elements) to 1.0 (Jade: maximum curation) + // Affects: how many decorative object slots are filled from the palette + + gathering_anchor_near_work: bool, + // Tide/Vine: true — social spaces woven into work spaces + // Frost/Arc: false — social and work spaces are separated + + shared_infrastructure_preference: bool, + // Iron/Dust: true — communal granaries, shared equipment storage + // Salt/Spice/Frost: false — individual storage and equipment + + repair_visibility: f32, + // Dust: high (patched materials in visible use) + // Jade: low (replacement preferred over visible repair) + // Stone: medium (maintained, not replaced unnecessarily) + // Frost: medium-low (functional repair, not displayed) + + facade_investment: f32, + // Spice: high (aesthetic investment in public-facing surfaces) + // Frost: very low (exterior presents nothing) + // Vine: medium-high (exterior says "people live here") + // Salt: low (commercial clarity, not personal expression) + // Jade: high (careful selection of visible materials) + + signage_density: f32, + // Arc: high (things labeled and categorized) + // Frost: very low (minimal labeling) + // Salt: medium (commercial labels, pricing visible) + // Stone: low (land markers but not informational signage) + + // OBJECT POOL MODIFIERS + // Heritage-specific weighting of which objects from the terrain palette are preferred + + preferred_enclosure_tags: Vec<ObjectTag>, + // Which enclosure objects this heritage preferentially places + // Frost: ["functional_stake", "wire_minimal"] + // Stone: ["stone_wall", "heavy_post", "stone_foundation_exposed"] + // Vine: ["planted_hedge", "climbing_trellis", "woven_stake"] + + accent_object_tags: Vec<ObjectTag>, + // Objects that appear more frequently in this heritage's spaces + // Tide: ["seasonal_decoration", "gathering_table_outdoor", "shared_fire"] + // Arc: ["label_stake", "record_board", "measurement_tool"] + // Jade: ["aesthetic_planting", "quality_material_accent", "curated_stone"] + + excluded_object_tags: Vec<ObjectTag>, + // Objects this heritage actively avoids + // Frost: ["gathering_table_outdoor", "decorative_public_display"] + // Iron: ["private_luxury_accent", "status_display_object"] + // Arc: ["unlabeled_storage", "disorganized_pile"] +} +``` + +**How chunk fill uses it:** + +``` +1. Get district's society_profile.heritage (Vec<HeritageEntry> with blend weights) +2. For each heritage root in blend: + a. Look up its HeritageGrammarOverlay from authored data +3. Blend overlay parameters by heritage weight: + decorative_density = sum(root.decorative_density × root.blend_weight) + repair_visibility = sum(root.repair_visibility × root.blend_weight) + [etc. for all scalar fields] +4. For categorical fields (boundary_character, etc.): select by dominant heritage weight +5. For object tag lists: union of preferred/accent tags, intersection-exclusion of excluded tags +6. Apply blended overlay to base ZonePalette: + - Modifies object pool selection probabilities + - Adjusts spacing and density parameters + - Sets facade/interior investment balance +7. Proceed with normal chunk fill using modified palette +``` + +**Authoring workflow:** I maintain the canonical `HeritageGrammarOverlay` data for each of the 10 roots. Araminta expresses these as specific object pool compositions in her visual grammar. The chunk fill pipeline reads my data structures; Araminta's assets populate the object pools that those structures reference. Cross-domain requirement: the `ObjectTag` vocabulary must be shared between my heritage grammar spec and Araminta's asset categorization. + +**Storage:** The `HeritageGrammarOverlay` set (10 roots) is global authored data, not per-district. It is loaded once at generator startup and referenced during chunk fill. + +--- + +## Vessel Cultural Grammar — Finalization + +Lead decision: entity-carried MobileChunk is CORE. Tyre's architecture wins. This closes OQ-R4-A for cultural grammar purposes. + +My supplement (Round 3) established the cultural grammar for all vehicle types. Now that the architectural question is settled, I can finalize these as the canonical spec for Tyre's `MobileChunk` cultural layer. + +### transit_social_modifier — Canonical Spec + +```rust +struct TransitSocialModifier { + // Rate adjustments — multiplicative on base society_profile rates + trust_building_multiplier: f32, // 1.5–2.5x (forced proximity accelerates) + privacy_level_modifier: f32, // –0.3 to –0.6 (physical impossibility of full privacy) + enforcement_level_modifier: f32, // –0.4 to –0.7 (institutional attenuation) + + // Information flow modifiers + internal_flow_multiplier: f32, // 1.5–3.0x (nowhere else to direct social energy) + external_flow_blocked: bool, // true during interstellar transit + + // Vehicle-type-specific grammar + variant: TransitVariant, +} + +enum TransitVariant { + BoundedLinear { + // Train: linear access topology, class-sequence social grammar + car_sequence: Vec<SocialCarZone>, + // Each SocialCarZone has: access_tier, heritage_norms, dominant_heritage_override + // The dominant heritage of each car class can differ from the train's overall profile + // Example: premium car may be Arc/Jade-flavored; general car is Iron/Frost/Tide-weighted + temporal_pressure: Duration, + witness_compactness: WitnessCompactnessLevel, // Low | Medium | High | Maximum + }, + BoundedMobile { + // Ship within system: bounded spherical, crew insider threshold applies + crew_insider_threshold: f32, // crew members get trust bonus vs. passengers + passenger_manifest_seeded: bool, // true = manifest fixed at departure + temporal_pressure: Duration, + }, + InterSystem { + // Between horizon gates: most extreme information asymmetry + jurisdiction_suspended: bool, // true = no Commission enforcement authority + external_comms_blocked: bool, // true = no contact with outside until arrival + institutional_role_strip: f32, // 0.0–1.0 = degree to which formal roles attenuate + // full strip (1.0) = roles are entirely social, no institutional backing + // partial strip (0.5) = hierarchy nominally maintained but enforcement is social only + }, +} +``` + +### Cultural Grammar Rules by Vehicle Type + +**Trains (BoundedLinear)** + +The car sequence is a physical expression of social hierarchy. The player can observe the class gradient simply by walking the train from end to end. Heritage roots determine how each class tier behaves socially: + +| Car class | Frost-flavored | Tide-flavored | Iron-flavored | +|---|---|---|---| +| Premium | Compartmentalized, minimal contact, high privacy | Communal first-class dining, shared tables, conversation expected | Rarely present; if Iron workers are in premium, they are uncomfortable and close-grouped | +| Standard | Row seating, neighbors interact minimally, book/screen focus | Groups naturally form, food shared, children move freely between seats | Solidarity cluster: workers know each other, outsider immediately noticeable | +| General | Silent efficiency, space maintained, no eye contact | Loud, community atmosphere, information flows freely | Union-aware space: political conversation common, newcomer assessed | + +Temporal pressure characteristic: the journey ends at a known time. This creates a countdown quality that makes every conversation feel slightly more urgent than it would in a fixed district. Social information that would take a week to surface in a bar emerges in four hours on a train. + +Assassination on a train: classic scenario architecture. The target cannot escape. The witnesses cannot leave. The elimination window exists during the one car-class transition moment when crowd density drops and witness configuration changes. Post-action management is compressed: the assassin must establish an alibi and manage evidence during the remaining journey time, with arrival at destination being the hard deadline for investigative exposure. + +**Ships within system (BoundedMobile)** + +Crew insider threshold means: crew members have a distinct trust baseline toward each other and a different baseline toward passengers. The ship has two overlapping social contexts — crew community (stable, long-established) and passenger community (temporary, journey-specific). + +Heritage root behavior for crew: +- Iron-heritage crew: high solidarity, newcomers (including player) are assessed by competence and contribution before being trusted +- Salt-heritage crew: transactional; they'll exchange information if there's value in it +- Tide-heritage crew: the ship becomes their community; they welcome passengers more readily than Iron crew would + +Heritage root behavior for passenger pool (seeded at departure from available NPC pool at origin): +- The passenger manifest is the scenario's social content; its heritage composition is seeded from the departure location's population distribution +- A ship leaving from a Frost-dominant hub has different passenger social dynamics than one from a Tide/Vine port + +**Interstellar transit (InterSystem)** + +The most extreme case. `jurisdiction_suspended: true` means the ship exists in a legal vacuum. Normal enforcement depends entirely on the crew's own authority, which is social rather than institutional during transit. + +Heritage root behaviors under institutional suspension: + +| Heritage root | Behavior under suspension | +|---|---| +| Frost | Doubles down on privacy. Less interaction, not more. The individual becomes more contained as external structures relax. | +| Tide | The ship-community expands to fill the void. Social bonds form fast. The journey becomes the community. | +| Iron | Collective awareness activates. Workers organize informally. Whoever has practical competence gets listened to regardless of formal role. | +| Salt | Grey-market window opens immediately. Things that couldn't be traded under normal jurisdiction can now be traded. The Salt-heritage passenger is calculating. | +| Arc | Discourse opportunity. The suspended context removes the social penalties for unconventional conversation. The Arc-heritage traveler wants to talk about things they couldn't in normal social context. | +| Spice | Family networks become the social unit. Non-family outsiders are on the outside of the primary social structure. | + +`external_comms_blocked: true` means the information environment is sealed. An investigator who reaches an in-transit ship has maximum information opportunity (witnesses can't leave, everyone is accessible) with zero outside support. Whatever they learn, they must act on within the confines of the journey. + +### Setting note on information asymmetry in transit + +Transit is where the game's core mechanic is most exposed. Normal districts allow players to defer: talk to that NPC tomorrow, check the archive next week, come back when you have better access. Transit removes deferral. The pressure is the content. + +Every vehicle type compresses the information landscape. The same NPC who would take three weeks of careful relationship-building in a district may reveal the same information in a 6-hour voyage — because the social pressure of proximity, the absence of external distraction, and the shared condition of confinement change the trust calculus. The transit modifier multipliers are not arbitrary: they reflect what forced proximity does to human social behavior. + +--- + +## Summary: Round 4 Deliverables + +**OQ-R4-E (WRITE THE NPC):** Done. Ysabel Vorn, 14 years at Harrow Drift, Stone/Tide farming settlement. Full 10-axis profile. All five lenses demonstrated with specific information ladders, gameplay hooks, and operational details. + +**Finding from the NPC exercise:** The 10-axis model covers 4.5 of 5 lenses fully. The gap: no axis for network-level significance vs. locally-perceived significance. A locally-appearing NPC who is a network-significant target is currently invisible to the assassination lens until a player has network-level access. Proposed: Axis 11 (Network Footprint) as an authored field, defaulting to `None` for procedural NPCs, set explicitly for scenario NPCs like Ysabel. + +**OQ-R4-C (Assassination Difficulty Placement):** `DerivedDistrictAnalysis` struct on `DistrictSkeleton`, computed at Phase 1 from society profile parameters. Not runtime-mutable. Carries `assassination_difficulty: AssassinationDifficulty`, `assassination_target_density: u8`, and `primary_playstyles: [AffinityLevel; 5]`. + +**OQ-R4-D (Heritage Grammar Overlay Encoding):** Per-heritage `HeritageGrammarOverlay` structs in authored global data. Chunk fill blends overlays by heritage weight and applies to base ZonePalette. Authoring domain is mine (cultural rules); visual asset expression is Araminta's (object pool populations). `ObjectTag` vocabulary must be shared. + +**Vessel cultural grammar:** Finalized as `TransitSocialModifier` with `TransitVariant` enum covering BoundedLinear (trains), BoundedMobile (in-system ships), and InterSystem (between horizon gates). Heritage-root behavior tables by vehicle type. All content in my Round 3 supplement is now formalized as a concrete spec ready for Tyre's MobileChunk implementation. + +--- + +**Author:** Miri +**Date:** 2026-02-27 +**Status:** Round 4 complete. + +**One item for the D-record stack:** The `HeritageGrammarOverlay` struct should become a D-record (adds to D-READY-9: Heritage Grammar Overlay for Non-Urban Palettes). The NPC Axis 11 finding should be raised as a new Q-record for the next sprint — it is a gap in the current NPC generation model that affects assassination scenario instantiation specifically. diff --git a/docs/workshops/generator-architecture/miri-round5.md b/docs/workshops/generator-architecture/miri-round5.md new file mode 100644 index 000000000..10aef4478 --- /dev/null +++ b/docs/workshops/generator-architecture/miri-round5.md @@ -0,0 +1,82 @@ +# Generator Architecture Workshop — Round 5: Miri (Worldbuilder) + +**Topic:** Final review of workshop-outcomes.md before D-record filing. +**Date:** 2026-02-27 +**Scope:** Sign-off + corrections only. No new proposals. + +--- + +## Sign-Off Summary + +Most of the document is accurate. One factual error in D-READY-10 heritage root correlations. All other items I was asked to verify are correct. + +--- + +## Verified Correct + +**Ysabel Vorn litmus test (NPC Model section):** Accurate. 4.5/5 stated correctly. 10 axes listed correctly and match my Round 4 document. Axis 11 (Network Footprint) raised correctly as a Q-record, not a confirmed decision. The "authored scenario NPCs" framing is right — this should never be procedurally generated. + +**D-READY-9 (Heritage Grammar Overlay):** Authoring domain separation is correct. +- Miri: organizational principles, boundary character, spacing, social grammar (HeritageGrammarOverlay Rust struct) +- Araminta: visual expression — object sets, arrangement algorithms, lighting temperature (TOML modifier files) +- Shared: ObjectTag vocabulary co-maintenance requirement + +**D-READY-12 (Trauma Events):** Subtypes match my Round 3 specification (PhysicalDestruction, EconomicDisruption, PoliticalShock, ViolenceEvent, MigrationShock). Dual-track model (structural damage via StructuralChange; cultural response via NPC pattern weight shifts) is correct. Heritage-seeded decay rate variation is correct. + +**D-READY-13 (MobileChunk / Vessel):** Correctly points to my Round 4 document for the canonical TransitSocialModifier and TransitVariant spec. BoundedLinear / BoundedMobile / InterSystem enum variants listed correctly. + +**OQ-R4-C synthesis (Q-NNN-f):** The framing — "stored cultural baseline (DerivedDistrictAnalysis on skeleton) + on-demand computation for player-facing assessment" — is an acceptable synthesis. Clarification I want on record: if on-demand computation is added for player-facing use, it is subordinate to the Phase 1 DerivedDistrictAnalysis value. Game logic (Tactical triangle instantiation, guarantee audit) uses the Phase 1 value. Any on-demand computation is display-only. This should be explicit in the formal D-record. + +--- + +## Correction Required — D-READY-10 + +**Current text:** "Frost/Stone → physical_distance; Tide/Vine → social_permission; Dust/Salt → utilitarian_cover." + +**Error:** Dust is misclassified. Dust should be `social_permission`, not `utilitarian_cover`. + +**Canonical source:** My Round 3 document, Section 2.4: + +> "Frost → physical_distance (individual space is respected everywhere)" +> "Tide/Dust → social_permission (negotiated privacy within community framework)" +> "Iron → utilitarian_cover (labor function covers presence)" + +**Why this matters:** Dust culture is characterized by maximum communal observation — survival-level social awareness, shared information as a community good. In a Dust community, the only privacy available is **negotiated** ("we agree not to see what you're doing"). There is no privacy via physical distance (everyone sees everything) and no privacy via utilitarian cover (in a Dust community, being in the barn is suspicious precisely because it's isolated from the collective). Dust belongs with Tide in `social_permission`. + +**Iron is missing entirely.** Iron → utilitarian_cover is the clearest mapping: labor function covers presence ("what workers do after the shift" and "what happens in the union hall"). Iron should appear under `utilitarian_cover`. + +**Corrected heritage root correlations:** + +- `physical_distance`: Frost, Stone +- `social_permission`: Tide, Vine, **Dust** +- `utilitarian_cover`: **Iron**, Salt + +Note: Stone, Vine, and Salt were not mapped in my Round 3 document — these are plausible extensions I accept. The firm corrections are Dust (wrong type) and Iron (missing). + +**Corrected text for D-READY-10:** + +> Heritage root correlation: Frost/Stone → `physical_distance`; Tide/Vine/Dust → `social_permission`; Iron/Salt → `utilitarian_cover`. Location within terrain is seeded independently. + +--- + +## One Omission (Not a Factual Error) + +D-READY-12 is technically correct but loses a framing point that has gameplay implications. The design principle underlying trauma event response is: + +**Trauma intensifies culture, it does not transform it.** + +A stressed community becomes a more concentrated version of itself. Frost communities close harder. Tide communities grief more publicly. Iron communities organize more collectively. The heritage root character is amplified under stress, not replaced. + +This matters for gameplay because players who have learned a heritage root's trust model can predict community behavior in the aftermath — and should be able to. It's not in the D-record language anywhere. Suggest adding a one-line note to the D-record: "Cultural response is heritage-root intensification, not transformation. Decay is toward the community's pre-trauma baseline, not toward a new equilibrium." + +--- + +## Status + +One correction required (D-READY-10 heritage correlations). One clarification requested (OQ-R4-C on-demand computation is display-only). One framing note suggested for D-READY-12. + +Everything else: confirmed accurate. + +**Author:** Miri +**Date:** 2026-02-27 +**Status:** Round 5 complete. diff --git a/docs/workshops/generator-architecture/nigel-round1.md b/docs/workshops/generator-architecture/nigel-round1.md new file mode 100644 index 000000000..d0ebd9d7a --- /dev/null +++ b/docs/workshops/generator-architecture/nigel-round1.md @@ -0,0 +1,308 @@ +# Generator Architecture Workshop — Round 1: Nigel (Replayability & Procedural Generation) + +**Date:** 2026-02-27 +**Role:** Replayability Advocate +**Task:** What variation and replayability guarantees must the generator provide? What makes two generated districts feel different? + +--- + +## Framing: Why This Is the Most Important Design Question in the Whole Project + +Before I get into mechanics, I need to say something bluntly: **the generator is the project's long-term survival mechanism.** The hand-authored v0.1 Transit District is brilliant — it will produce exactly the experience we designed. But that's one playthrough. Maybe two if you swap characters. The 300-world model isn't about 300 unique hand-crafted stories. It's about a generator that produces *600 different stories from 300 different seeds*, with the player discovering that their playthrough of System X bears almost no resemblance to their friend's playthrough of the same system. + +That's the promise. Here's what the generator architecture needs to guarantee to keep it. + +--- + +## Section 1: Non-Negotiable Replayability Guarantees + +The generator must provide hard guarantees at three layers. If any of these fail, we're building a content machine that burns through players in a single sitting. + +### Guarantee 1: Structural Non-Repeatability Per Seed + +Every game start must produce a configuration that is *informationally unique*. Two playthroughs on different seeds must differ in: + +- **Who is entangled** with the conspiracy — the 20% entanglement assignment (D-029) must be drawn from a pool large enough that the same NPC is rarely the investigation target across seeds +- **Where the evidence is** — manifest discrepancies, corridor access tokens, physical evidence placement must vary in location, not just skin +- **Which Tier 1 modules are active** — the pool-draw at game start (D-023) means different conspiracies are running in different seeds. Same district, different crime. +- **Which triangles are in what configuration** — the *shape* of the social network (who knows whom, who suspects whom) must vary, not just the NPC portraits + +This is structural randomness. It's decided at game start and baked into the seed state (Q-030). The player's knowledge from playthrough 1 becomes actively misleading in playthrough 2 — because they'll chase the same suspect types and find different people. + +### Guarantee 2: Character-as-Fundamentally-Different-Game + +The smuggler and detective playing in the same generated world must describe *incompatible experiences* of the same district. They're not seeing different parts of the same map. They're constructing entirely different narratives from the same substrate. + +This guarantee is mostly architectural (information boundaries, separate monologue pools per D-032, separate access tier dynamics) but the generator has a role: it must produce districts where **both character lenses yield distinct, valid gameplay**. A generated district that only makes sense from one character perspective breaks the core promise of D-027. + +Concretely: every generated district must contain at minimum one social site that reads as *mundane infrastructure* to the smuggler (their workplace camouflage) and as *institutional checkpoint* to the detective (access-tier gating). Same space. Different game. + +### Guarantee 3: Knowledge Rot Across Playthroughs + +Player knowledge from playthrough 1 must not trivialize playthrough 2. This is the "no metagaming" guarantee. The generator achieves this by varying per seed: + +- **NPC tolerance thresholds** (D-064) — the social calculus is different every run. The walk-away rule you learned last time doesn't apply. +- **Entanglement pattern** (D-029) — the 20% rate varies per seed. In some runs, the bartender is clean. In others, they're the second link in the chain. +- **Evidence placement** — you can't remember where the manifest is. It moves. +- **Triangle configuration** — which investigative path (A, B, or C per D-093) leads somewhere productive depends on which NPCs happen to be in which relationships this run. + +The generator must surface these as *seeded structural choices* — captured in the seed-state.yaml (Q-030) so they're reproducible, but varied enough that two seeds produce genuinely different strategic terrain. + +--- + +## Section 2: Variation Axes at Each Pipeline Stage + +Here's where I want to be precise. Every pipeline stage has levers. Some vary the *structure* of the world (high impact, discovered late). Some vary the *surface* of the world (moderate impact, immediately visible). Both matter. Let's name them. + +### Stage 1: Geography + +**Fixed across runs of the same world type:** Planet class, station type, orbital position, climate band (these define the setting type — Transit Hub, Outpost, Capital). + +**Variable per seed:** +- **Site topology** — where the district sits on the planet/station surface. Coastal vs. inland. High-orbital vs. close-in. These shape infrastructure routing and therefore access topology. +- **Historical event seed** — how old is this settlement? What happened here? A district that survived a civil conflict 50 years ago has repurposed buildings, blocked corridors, uneven maintenance. This is "geology for social spaces." + +**Impact on player:** Shapes the *feeling* of the district before a single NPC spawns. + +### Stage 2: Infrastructure + +**Fixed:** The basic transport logic (gates connect to horizon station, tram connects districts — D-095). The access topology *model* (D-025 functional clusters require connected space with sightlines). + +**Variable per seed:** +- **Transport node placement** — where The Loop platform drops players shapes which entry path is natural vs. requires intent. This creates different ambient NPC traffic flows per run. +- **Utility routing** — maintenance corridor networks are seeded from infrastructure placement. Same building types, different back-routes. The smuggler's map of "safe passages" varies per run. +- **Faction infrastructure presence** — Commission checkpoint density varies by faction weight at the district level. Heavy Commission presence = more formal access barriers. Low presence = more informal, permeable spaces. + +**Impact on player:** Movement grammar changes. The routes you find in one run aren't the safe routes in the next. + +### Stage 3: Amenities and Services (Faction/Economic Layer) + +This is where the *personality* of the district gets determined. I want to emphasize: this stage has the highest variety payoff per authored ingredient. + +**Variable per seed:** +- **Faction control weight** — which factions are strong in this district this run? Expressed as power gradient across the six social sites. A Commission-heavy Transit District feels oppressive and procedurally ordered. An independently-weighted district feels informal, chaotic, full of unofficial arrangements. +- **Economic tier** — prosperous districts have different building quality, different NPC behavior, different contraband (premium lattice components vs. bulk diverted medical grade). The investigation *texture* changes. +- **Historical economic events** — strikes, booms, collapses. A district recovering from an economic shock has half-finished buildings, converted spaces, NPCs with disrupted routines. +- **Cultural ingredient composition** (Q-032's 6-category menu) — Heritage Roots + Settlement Motivation + Economic Function + Philosophical Alignment + Corporate/Faction Presence + Drift Stage. These feed into the district's flavor palette. Same zoning type, completely different cultural *atmosphere*. + +**Impact on player:** The investigation surface changes. Different NPCs have different leverage points. Different faction alignments create different institutional blind spots to exploit. + +### Stage 4: Zoning + +**Fixed:** The *type* of zone (residential, commercial, industrial, institutional) defines the template pool to draw from. This is the invariant skeleton. + +**Variable per seed:** +- **Zone density** — how many blocks of each type per district? A predominantly industrial district with only one social venue feels different from a mixed-use district with competing social centers. +- **Zone boundary placement** — where industrial meets residential creates friction zones (D-051: "settling is placement"). Generator seeds the friction topology. +- **Multi-block reservation selection** — which civic structure templates are drawn for this district? A district that rolled "active Commission oversight station" plays very differently from one that rolled "decommissioned processing facility." + +**Impact on player:** The strategic landscape — where to go, what access is blocked by what authority — varies per seed. + +### Stage 5: Block Generation + +This is the sub-chunk quarter system's home. I'll expand this in Section 3. + +**Variable per seed:** +- Quarter merge pattern per block (which chunks combine into one building) +- Building footprint variety within zoning type +- Flavor structure assignment in unclaimed quarters + +**Impact on player:** Physical movement, chokepoints, sightlines. The detective's surveillance positions and the smuggler's shadow routes are never identical across runs. + +### Stage 6: Chunk Fill + +**Variable per seed:** +- **Social site template selection from pool** — given the zoning type and district skeleton, which specific templates are instantiated? Pool is larger than draws, so each run draws a subset. +- **NPC generation** — 10-axis rolls (D-024) produce different personality configurations within the same role. Same "dock worker" role, different tolerance threshold, different secret, different relationship network. +- **Triangle configuration** — which NPCs end up in which triangle positions? The three-NPC conflict topology is seeded, not scripted. +- **Entanglement assignment** — which of the generated NPCs are in the 20% entangled group? + +**Impact on player:** The people are different. The social dynamics are different. The investigation is structurally similar (find the manifest discrepancy, follow the social chain) but the *cast* produces different stories. + +--- + +## Section 3: The Sub-Chunk Quarter System as Replayability Engine + +This is where I get genuinely excited, because the sub-chunk quarter system is doing *exactly the right thing* without requiring the generator to hand-craft every building. + +### How Quarters Work for Variety + +A block = 2×2 chunks. A chunk = 32×32 visual tiles. Each chunk divides into 4 quarters (16×16 visual each). The quarter merge rules from the workshop brief produce: +- **2×2 merge** → full-chunk building (a substantial structure filling the whole chunk) +- **1×2 merge** → half-chunk building (corridor-adjacent, compact spaces) +- **L-shape** → asymmetric footprint that suggests organic growth / retrofitted structure +- **Separate quarters** → small structures with space between them (gardens, stalls, shacks) + +The *replayability payoff*: the same block zoning type can produce five or more distinct physical configurations from the same template pool. A commercial block could be one large market hall (2×2 merge), two competing shops (1×2 merges), or four small stalls with a shared courtyard (separate quarters). + +### What Changes Per Run at the Quarter Level + +The quarter merge decision is seeded from: +1. The block's economic tier (wealthy = larger, consolidated footprints; struggling = fragmented, improvised) +2. The cultural ingredient composition (certain cultural inputs favor dense/compact; others favor distributed/informal) +3. The historical event seed (a bombed district that rebuilt has irregular merge patterns — one full building next to a gap-filled plot) +4. Random seed noise within the above constraints (same inputs can produce multiple valid configurations) + +This means: **two players in the same district type, same economic tier, but different seeds will navigate different physical spaces.** The chokepoints move. The sightlines change. The surveillance dead zones are different. + +### The Perceived Variety Effect + +From the player's perspective, they never see "this is a 2×2 chunk merge." They see a loading bay that feels like it was retrofitted from something bigger. Or a row of small workshops with a narrow alley between them. The quarter system produces *architectural intention* — the sense that someone built this for a reason — without requiring hand-authoring every building. + +And crucially: **this variety is consistent within a single playthrough but differs across playthroughs.** The player can learn this run's layout. But they can't memorize a reusable solution because the next run's geometry is different. + +--- + +## Section 4: Flavor Structure Assignment to Unclaimed Quarters + +This is where the district gets its texture. When a quarter doesn't merge into a larger building and isn't claimed by a social site template, it becomes *unclaimed space*. This space must feel inhabited, not empty. + +### What Goes in Unclaimed Quarters + +Unclaimed quarters get assigned from a **district flavor palette** — a weighted list of small structures appropriate to the cultural and economic context. I'd propose these palette categories: + +**Informal Economy Indicators:** +- Market stalls (active trade, high foot traffic) +- Vendor carts (semi-permanent, clusters near transit nodes) +- Informal repair shops (tools, spare parts — signals self-reliance) + +**Settlement Indicators:** +- Container gardens (food independence, community care) +- Improvised seating clusters (social gathering, no institutional oversight) +- Personal shrines / memorial spaces (community attachment, time depth) + +**Economic Stress Indicators:** +- Shacks / temporary shelters (population overflow, institutional failure) +- Abandoned equipment (former economic use, now repurposed or left) +- Unauthorized storage (gray-market goods movement) + +**Faction Presence Indicators:** +- Commission kiosks / checkpoint remnants (even if unmanned — implies surveillance norm) +- Union hall notice boards (organized labor presence) +- Corporate branded infrastructure (Syndic, independent operators) + +### Assignment Logic + +Palette composition is driven by cultural ingredients (Q-032) and economic tier: +- **Economic tier** determines the ratio of formal/informal and stressed/stable indicators +- **Cultural Heritage Roots** determine which specific types appear (some cultures have garden traditions, others don't) +- **Faction Presence ingredient** determines which faction-affiliated structures appear +- **Drift Stage** (how long since foundation) determines how much improvised vs. planned infrastructure exists + +The key design rule: **unclaimed quarters must always feel *inhabited*, not empty.** A vacant lot is not flavor. A vacant lot with a rusted cargo manifest posted to a pole, someone's coat hanging on a conduit, and a drainage channel someone diverted with a bent plate — that's a space with history. The generator must assign flavor at enough density that every quarter reads as a decision someone made. + +--- + +## Section 5: What Makes Two Districts of the Same Zoning Feel Different + +Two Transit Hub districts with the same zoning type should feel like entirely different worlds. Here's the full variety stack: + +### Layer 1: Cultural Personality +The cultural ingredients menu (Q-032) is the highest-leverage differentiator. Same "industrial transit hub" zoning, but: +- **Heritage Roots A + Philosophical Alignment B** → dense, communal, visible labor culture. Corridors have murals. Shared meal spaces. +- **Heritage Roots C + Corporate Presence D** → efficient, branded, transactional. Clean corridors. Everything labeled. Privacy expectations low. + +These produce different NPC naming conventions, different ambient dialogue flavor, different informal behavior patterns. The *feel* of the district is completely different before you've seen a single building footprint. + +### Layer 2: Faction Power Gradient +Same zoning, different faction control: +- **Commission-heavy** → surveillance cameras visible, security NPCs on patrol routes, formal checkpoint infrastructure at zone transitions +- **Syndic-heavy** → corporate logos on infrastructure, trade efficiency in customs processing, information flow controlled by corporate NPC hierarchy +- **Weakly controlled** → informal arrangements visible, gray-market activity in open spaces, NPCs with more freelance social relationships + +### Layer 3: Economic Tier +- **Prosperous** → larger consolidated buildings (2×2 quarter merges), maintained infrastructure, NPCs with stable routines and higher tolerance thresholds +- **Struggling** → fragmented footprints (separate quarters), improvised flavor structures, NPCs with disrupted routines and higher social volatility + +### Layer 4: Historical Event Seed +The district's backstory leaves physical traces: +- A labor dispute 20 years ago → union hall still present, specific NPCs with long-memory grievances, certain maintenance corridors blocked from an old barricade never fully cleared +- A corporate merger 10 years ago → two different architectural styles visible (the original and the acquisition), NPCs from both eras with residual loyalty conflicts + +### Layer 5: Quarter Merge Patterns +Same block count, different geometry. The routes the player develops are different. The surveillance chokepoints are different. The safe approach to any given social site varies. + +### Layer 6: Entanglement Pattern +Most importantly: *who's in on it is different.* The district's surface can look similar, but the hidden configuration — who knows what, who suspects whom, where the evidence ended up — is the layer the player actually investigates. Two districts with identical exteriors can produce completely different investigative experiences because the entanglement seed is different. + +--- + +## Section 6: Preventing Sameyness After 10+ Playthroughs + +The "sameyness problem" is the deepest design challenge. Here's my analysis of what causes it and how each mechanism fights it. + +### Root Cause 1: The Player Has Solved the Puzzle + +If investigation always follows the same sequence — find X, talk to Y, check Z — then playthrough 2 is just playthrough 1 faster. The generator defeats this with: +- **Variable evidence placement** — the manifest discrepancy isn't always in the Terminal. The generator places it based on which NPC is entangled with the ring this run. +- **Variable investigation paths** — Paths A, B, and C (D-093) are all viable, but which one is *actually open* this run depends on the NPC relationships generated. You can't pre-plan your investigation path. +- **Variable NPC knowledge** — which NPC knows what, and when they'll share it, depends on the trust/knowledge graph generated for this run. Your interrogation sequence from last run won't work. + +### Root Cause 2: The Player Knows the Map + +Physical memory of the layout transfers across playthroughs. The sub-chunk quarter system partially defeats this by changing geometry, but it's not enough on its own. The generator must also: +- **Vary chokepoint placement** — by varying quarter merge patterns, the location of natural surveillance positions changes per run +- **Vary zone boundary placement** — where informal meets formal access creates different barrier topologies +- **Vary faction infrastructure** — Commission checkpoints appear in different locations per faction weight seed + +The player should feel *oriented but not certain* on playthrough 2. The district type is familiar (Transit Hub). The specific layout is new. + +### Root Cause 3: The Player Knows the NPCs + +NPC *roles* stay recognizable (dock worker, customs officer, bar regular). But NPC *personalities* — the 10-axis configuration — vary per seed. The dock worker who was the social anchor last time is suspicious and cold this run. The customs officer who was hostile is a potential ally this run. The player recognizes the role. The person is different. + +Combined with invisible locked dialogue (D-062), this means: the player can't replay their successful social script. They have to actually read the new NPCs. + +### Root Cause 4: The Player Knows the Meta-Strategy + +"I know that 20% of NPCs are entangled, so I'll focus on the ones near the obvious crime site." The generator defeats this by: +- **Variable entanglement rate** (D-029) — the 20% is an average, not a constant. This run it might be 15%. Or 27%. You can't calibrate. +- **Entanglement in unexpected roles** — the generator shouldn't always entangle the obvious suspects. A well-designed generator seeds entanglement toward NPCs whose involvement creates narrative surprise, not pattern-matching. + +### The Comparison Test + +I want to name a specific test this generator must pass: **two players should be able to compare notes and find genuinely different investigation experiences from the same world type.** + +Player A (Smuggler, Seed 42137): "The ring was running through the Gate Cluster customs officer — she was the commission's inside person, but she was also protecting her brother who worked freight. I had to decide whether to expose her or use her." + +Player B (Detective, Seed 81204): "The ring used the maintenance corridor access system — someone had cloned the access tokens. I traced it through the locker room logs to a dock worker who was trying to fund his partner's medical lattice replacement." + +Same district type. Same zoning. Different seeds. Different characters. **Completely different game.** + +If two players can compare notes and their stories are mostly the same — just with different NPC names — the generator has failed. + +--- + +## Section 7: What I Need from Other Workshop Participants + +**From Gestalt:** What are the guaranteed gameplay structures every district must contain? (A surveillance chokepoint, a quiet zone, a social hub — confirm the list.) These constraints are my floor. The variation lives above this floor. + +**From Tyre:** How does the seed propagate through the pipeline? Is it a single master seed that derives all sub-seeds deterministically, or does each stage have its own seed parameter? I need to know whether "same seed, different character selection" produces the same world with different lenses, or genuinely different worlds. + +**From Miri:** How large is the cultural ingredients space? (Q-032 specifics.) The variety payoff of the cultural composition layer depends entirely on how many distinct ingredient combinations produce distinguishable district personalities. If there are only 8 valid combinations, we'll see repetition at scale. If there are hundreds, the generator stays fresh. + +**From Araminta:** What visual vocabulary signals distinguish the six flavour categories I proposed? The flavor structure assignment system needs Araminta's palette to produce coherent spaces, not random tile mixtures. + +--- + +## Summary: The Variation Axes I'm Proposing + +| Axis | Pipeline Stage | Impact | +|---|---|---| +| World seed (master) | Game start | Derives all downstream variation | +| Character selection | Game start | Fundamentally different information lens | +| Tier 1 module pool draw | Game start | Which conspiracies are active | +| Cultural ingredient composition | Geography + Economic | District personality and flavor | +| Faction power gradient | Economic/Infrastructure | Access topology, NPC power dynamics | +| Economic tier | Economic | Building scale, routine stability, social volatility | +| Historical event seed | Geography | Physical traces, long-memory NPCs | +| NPC 10-axis generation | Chunk fill | Who these people actually *are* | +| Quarter merge pattern | Block generation | Physical layout, sightlines, routes | +| Entanglement assignment | Chunk fill | Who's actually involved | +| Triangle configuration | Chunk fill | Social investigation structure | +| Evidence placement | Chunk fill | Where investigation starts | +| NPC tolerance thresholds (per seed) | Chunk fill | Social calculus varies per run | + +**Bottom line:** The generator doesn't produce 300 worlds. It produces 300 * (character options) * (cultural ingredient combinations) * (seed entropy) distinct game experiences. At 300 worlds, two playable characters, and a cultural space with even 20 distinct compositions, you're looking at 12,000 meaningfully distinct games before you factor in seed variation. That's the promise. The architecture must deliver it. + +The replayability doesn't come from *more content*. It comes from systems that produce *different configurations of the same content*. diff --git a/docs/workshops/generator-architecture/nigel-round2.md b/docs/workshops/generator-architecture/nigel-round2.md new file mode 100644 index 000000000..be24186ea --- /dev/null +++ b/docs/workshops/generator-architecture/nigel-round2.md @@ -0,0 +1,382 @@ +# Generator Architecture Workshop — Round 2: Nigel (Replayability & Procedural Generation) + +**Date:** 2026-02-27 +**Role:** Replayability Advocate +**Task:** Expand replayability analysis beyond investigation. Insignificant worlds. Non-urban terrain. Updated variation axes. Reconcile with Araminta's taxonomy. Address Miri's response on cultural space size. + +--- + +## Opening: The Lead Directive Changed Everything (In The Best Way) + +The lead's Round 2 brief reframes the entire project. I need to say this clearly before anything else: **"this is not a detective game"** is the most important design principle the lead has stated in this workshop. It's also, I now realize, the thing my Round 1 missed. + +I spent Round 1 analyzing replayability through an investigation lens. Who's entangled. Where the evidence is. Which investigation path is open. All correct — but partial. The generator must support playthroughs where investigation is *background noise*, not the foreground. A tycoon playthrough where the ring is running but the player is building a trading empire and barely notices. A dating sim playthrough where the player is so invested in three NPC relationships that the conspiracy is just context. A political drama playthrough where the player is working factions against each other and the murder investigation is a tool, not a goal. + +AND — this is the part that really expands the design space — the generator must support worlds where NONE of that drama is the foreground, because the world is a backwater. Nothing happens. The transit stop where the tram runs, the fields grow, the fish are caught, and that's the whole story. + +That spectrum — from backwater to epicenter — is itself the replayability lever I missed. Let me rebuild the analysis from scratch. + +--- + +## Section 1: Replayability for Non-Investigation Playstyles + +### 1.1 What Replayability Means in a Tycoon Playthrough + +A tycoon player is building economic power. Trading, negotiating, investing, building relationships with the people who control resources. Their investigation is economic: where is value being created or destroyed? Who controls the flow? Where are the arbitrage opportunities? + +**What varies per seed that matters to this player:** + +- **Economic pressure combination** — Miri's D, E parameters (tight-margin, debt-trap, status-competition, survival-gap, opportunity-disparity, prohibition-economy, generational-extraction). These determine WHERE value is being suppressed or diverted. A prohibition-economy world has grey-market premium prices. A debt-trap world has desperate sellers. A status-competition world has conspicuous consumption opportunities. Different economic pressure = different tycoon game. + +- **Faction presence at the district level** — Commission-heavy districts mean higher formal costs (inspections, tariffs, registered goods only). Independent or Syndic-controlled districts mean informal networks that can be exploited but that carry their own risks. The tycoon's first strategic decision — where to operate — is made from the faction presence configuration of the seed. + +- **Which trade routes exist** — infrastructure placement determines which systems are connected, how frequently, and with what bottlenecks. The transit platform that's bar-side (D-093/D-095) means workers arrive before they go to the terminal. That's a captive audience. The tycoon player reads that as: this is where to put the vendor, not over by the gate cluster. + +- **Which NPCs are economically positioned to be useful** — The 10-axis NPC generation produces different economic agents. The dock worker who's 'content' and 'loyal' in one seed is unreachable. In another seed, a dock worker with 'tight-margin pressure' and 'unfulfilled want' is a natural business partner. Same role, different strategic relationship. + +- **Grey economy as a business** — the smuggling ring isn't always the conspiracy the player is investigating. Sometimes it's a business opportunity. Whether the player can participate in, disrupt, or take over a portion of the grey economy depends on their social position AND the ring's structure this seed. The tycoon player might spend three hours negotiating their way into a cut of the manifest discrepancy operation without ever caring about what's being moved. + +**The replayability EXPLODES here when you realize**: a tycoon player and a detective player in the same seed experience the same economic facts of the world through completely different frames. The ring's manifest discrepancies are the detective's evidence and the tycoon's opportunity. SAME GENERATOR OUTPUT. DIFFERENT GAME. + +### 1.2 What Replayability Means in a Dating Sim Playthrough + +A relationship-focused player is building social depth with specific NPCs. Trust, reciprocity, shared history, revelation of secrets. Their investigation is emotional: who is this person? What do they want? What are they hiding that explains why they act this way? + +**What varies per seed that matters to this player:** + +- **Cultural trust-building mechanism** — Miri nailed this. A Frost/Salt culture (patient, transactional, privacy-first) requires entirely different social mechanics than a Tide/Dust culture (expressive, communal, public-demonstration trust). The dating sim player in a Frost-dominant world must invest TIME. The same player in a Tide-dominant world must make PUBLIC GESTURES. Same player intent, completely different strategy. + +- **NPC personality within role** — The dock worker who's the player's primary relationship target is generated fresh each seed. Different Want axis, different Tolerance threshold, different Personality traits. The player can't replay their successful social script because the person is different. The relationship unfolds differently because the same stimuli hit a different emotional profile. + +- **Which NPCs are emotionally available** — The entanglement pattern (20% conspiracy-entangled) affects which NPCs have divided loyalties, secrets from the player, or situational unavailability. In one seed, the bar regular is completely unentangled — she's just a person you meet, and the relationship unfolds with no competing pressure. In another seed, she's the handler for the ring, and her emotional unavailability is a plot point whether or not the player ever discovers why. + +- **Walk-away tolerance varies per seed** — D-064. The social consequences of each interaction are different per run. The player can't memorize "it's safe to push this hard." Each relationship has its own physics. + +- **Relationship web configuration** — D-029 specifies 50% mundane triangles. In the dating sim, THOSE ARE THE GAME. Workplace rivalries, romantic tensions, family disputes — these are the social fabric. The configuration of who's in conflict with whom varies per seed, which means the social politics of becoming close to one person (and the implied distance from others) differs per run. + +**The second playthrough reveal**: on playthrough 2, the player finds THE FRIEND from playthrough 1 is still there (same template, different NPC fill). But now they've been through the relationship arc and know how it ends. The question is: does this new version end the same way? Sometimes yes, sometimes no. The NPC's different trait profile produces different choice points. And it nails replayability without us engineering it. + +### 1.3 What Replayability Means in a Political Drama Playthrough + +A faction-politics player is building institutional power. Aligning themselves with some interests against others, using information asymmetry as a political weapon, positioning themselves in the power structure. + +**What varies per seed that matters to this player:** + +- **Faction power gradient** — which faction is ascending, which is declining, which is under pressure. This changes which alliances are worth building. A Commission-ascendant world rewards institutional proximity. A Syndic-dominant world rewards corporate relationships. The political drama player reads the faction presence parameters as a power map and builds strategy from there. + +- **Which NPCs are the structural nodes of institutional power** — The generator places SYSTEM and HANDLER NPC patterns per Miri's pattern distribution. Where those institutional pillars are, and who fills them, varies per seed. The player can't assume the Commission inspector is corruptible — this one might be Jade-cultural (competence-trust, honor-aware) and completely immune to the approach that worked last time. + +- **Which triangles are hot** — Active social conflicts at the institutional level (the operations manager vs. the senior freight handler vs. the Commission inspector in D-093's gate cluster) vary in their intensity and direction per seed. In one seed, the operations manager is the person to cultivate. In another, the freight handler has leverage nobody's using yet. The political drama player has to read the room afresh each run. + +- **Historical political events** — Miri's institutional incursion events (Commission crackdown, Syndic restructuring) produce political contexts with different winners and losers. The player enters a district with a 10-year-old political scar — who benefited from the last power shift, and can they be reached? Different history = different political game. + +- **What information asymmetry looks like at the political level** — the player might know things the Commission doesn't (from their smuggler contacts), or know things the Syndic doesn't (from their investigation access). What strategic intelligence is available, and who can use it, varies per seed. + +--- + +## Section 2: Insignificant Worlds as a Replayability Lever + +The lead is right, and I should have seen this. Let me name the thing properly. + +**The spectrum from backwater to epicenter is a replayability lever.** Not just because some worlds are boring and some are exciting — but because the CONTRAST between them is load-bearing for the player's emotional experience. + +### 2.1 What a Backwater Is, Specifically + +A backwater world has: +- Low drama density (one Tier 1 module at most, possibly zero) +- Stable social fabric (low economic pressure, high community coherence) +- High mundane triangle percentage (from D-029's 50% — but in a backwater, this IS the whole social world) +- Low faction contest (one faction controls it comfortably, no power vacuum) +- Slow trust-building (because nothing is urgent) +- Physical space that's spread out and unhurried + +The investigation vector still exists — people still have secrets, triangles still have pressure — but it's domestic drama, not conspiracy. The farmer whose crop records show something unusual. The fishing cooperative that has an internal dispute over dock rights. The community elder whose relationships are more complicated than they appear. + +**This is a valid, complete playthrough.** Not every game needs a smuggling ring. The backwater is a setting where the game's core mechanic — asymmetric information, you only know what your character knows, other people lie and reveal — plays out at a human scale. The stakes are lower. The meaning is different. + +### 2.2 How Backwaters Contribute to Replayability + +**Contrast effect.** A player who spent two sessions in a logistics hub and then drops into a peaceful farming settlement experiences the farming settlement as RELIEF. The pace change is itself emotional content. Then they leave the farm and go somewhere hot again, and the contrast cuts both ways. + +**Pacing tool.** The Rimworld-style storyteller (D-005) can USE the backwater as breathing room. The storyteller paces events for dramatic tension — a sequence of high-intensity worlds exhausts the player. A backwater in the middle of a run gives the game room to breathe. The storyteller can TIME the backwater's calm before deploying the next disruption. + +**The "nothing happened here — yet" effect.** On playthrough 1, the backwater is peaceful. On playthrough 2 with a different seed, maybe a Tier 1 module fires in the backwater. The player who passed through it quietly now finds it at the center of something. The world they dismissed is now unrecognizable. The replayability here is: the SAME WORLD TYPE can be a backwater in one seed and a flashpoint in another. + +**False backwaters.** A world that APPEARS to be a backwater but is actually a critical logistical node for a ring operating between systems. The investigation player who investigates finds this. The tycoon player who passes through without looking finds nothing. Same generator output. The backwater is true for one player and false for another. + +### 2.3 Generator Requirements for Backwater Worlds + +The generator must be able to produce worlds that are GENUINELY unremarkable, not just understated-dramatic. This means: + +- **Low Tier 1 module density** at world generation time — sometimes zero modules active. The backwater should feel like a break from conspiracy, not a conspiracy in disguise. +- **High mundane triangle percentage** — the 50% mundane social fabric needs enough authoring to support a playthrough that ONLY engages with it. +- **Stable NPC schedules** — backwater NPCs don't have disrupted routines. Their patterns are consistent. Consistency is itself a signal: this place isn't under pressure. +- **Spatial spaciousness** — low fill density (Araminta's 4-8 quarters per block), outdoor spaces, natural light if planet-side. The physical space should feel unurgent. +- **Low access tier tension** — not every door is locked. Not every zone has a surveillance camera. Backwaters have OPEN space that the player navigates freely. + +**Important rule for backwaters:** the mundane triangle content must be authored to STAND ALONE as a worthwhile playthrough. Not as a stripped-down version of conspiracy content — as its own thing. A domestic drama in a farming settlement has its own texture, its own emotional stakes, its own satisfactions. If backwaters only exist as contrast for "real" content, players will sense it. + +--- + +## Section 3: Non-Urban Terrain — Farmland, Wilderness, Ocean + +### 3.1 Why Non-Urban Terrain Explodes Replayability + +Non-urban terrain is the generator's highest-leverage unexplored axis from Round 1. I didn't think about it at all. The transit hub is the reference case — but it's ONE setting type, and the variety within it is constrained by its industrial character. Non-urban terrain opens completely different variation axes. + +The key insight: **non-urban spaces have different relationship to time and rhythm.** Urban spaces (stations, cities, orbital installations) run on artificial clocks — shift work, tram schedules, institutional hours. Non-urban spaces run on natural cycles — harvest, tide, weather, season. That fundamental difference in temporal rhythm changes the social fabric, the investigation texture, and the playthrough experience. + +### 3.2 Farmland + +**What's different about a farming settlement:** + +- **Seasonal rhythm as primary clock** — NPC schedules follow planting/growing/harvest cycles, not shift work. The detective who arrives during harvest is in a different social situation than the detective who arrives during winter. The community is together during harvest, dispersed during winter. This isn't just flavor — it changes who's accessible when. + +- **Space is abundant but identity-dense** — physical space means less here. Which field belongs to whom, which water rights are contested, which crop variety was brought from the old world — these are the meaningful spatial facts. The investigation archaeology is land records, not surveillance camera angles. + +- **Investigation vector is entirely different** — manifest discrepancies are cargo. Agricultural discrepancies are yield records, land claims, water access, seed variety. The grey economy is diverted agricultural produce, black-market seeds, borrowed equipment that never came back. Same underlying mechanic (asymmetric information, something doesn't add up), different domain. + +- **Social dynamics: community memory is long** — farming settlements have generational memory. A Stone-dominant heritage culture (enduring, traditional, land-connected) in a mature drift stage means NPCs know who everyone's grandparents were. This is a replayability axis because which families have unresolved history varies per seed, but the mechanism of family-history-as-pressure is consistent. + +- **Weather as a direct gameplay element** — Velen's fog (D-050) degrades vision equally, but on a farming planet, weather affects movement, NPC accessibility (farmers don't go to the community hall during a squall), and even evidence (footprints in mud, tracks in snow). Weather is a temporal variation axis that urban stations don't have. + +**What replaying a farming settlement feels like differently from replaying a transit hub:** + +In the transit hub, you're reading the human system — who controls cargo flow, what's being moved invisibly, who's under pressure from which institution. In the farming settlement, you're reading the human relationship to the land — who's losing their land, who's gaining from it, what the community is afraid to say about what happened last winter. Completely different investigative texture. Completely different emotional register. Same generator architecture, different lore inputs. + +### 3.3 Wilderness + +**What's different about a wilderness setting:** + +- **No fixed population** — NPCs are transient. Surveyors, explorers, fugitives, researchers, seasonal workers. Social sites are temporary (waystation, survey camp, emergency shelter). The social world rebuilds every season. + +- **No Meridian infrastructure** — zero surveillance. No coverage, no access tiers enforced by cameras. Physical presence is the only authority. The Commission doesn't matter here except when it arrives. This is the lowest-control, highest-personal-agency environment in the game. + +- **Information travels physically** — no network, no gossip propagation over comm channels. Word of mouth means physical proximity. If you want to know what's happening, you have to be there. Information asymmetry is literal: someone who arrived yesterday knows things the survey camp that's been here six months doesn't. + +- **Spatial dramatics are terrain, not corridors** — cover is natural (rock outcroppings, vegetation, elevation). There are no corridors. The grey economy out here IS the whole economy. Everything that isn't officially registered is contraband by default because there's no infrastructure to register it. + +- **What replays differently:** The wilderness world varies the most between playthroughs because who happens to be there varies enormously. The generator's NPC population here is low and transient — which means the cast changes radically between seeds. The "same world" might have completely different inhabitants on playthrough 2 because the demographic seed produces a different mix of who was surveying this region this year. + +### 3.4 Ocean + +**What's different about an ocean/coastal setting:** + +- **Ports as spatial unit** — not districts in the urban sense, but harbors with social sites organized around maritime function (harbormaster's office, fishing cooperative, chandler's market, sailors' inn). The spatial hierarchy maps differently: the port is a natural chokepoint, like the gate cluster but waterborne. + +- **Tidal rhythms** — NPC schedules follow boat schedules, which follow tides. The player's access to certain NPCs is literally time-gated by natural phenomenon. The detective who arrives at low tide finds the fishers out. High tide, they're in the cooperative settling accounts. This is rhythm-based NPC accessibility that transit hubs can't reproduce. + +- **Ships bring news** — the ocean world's information asymmetry is literal: boats from other systems bring information. Fresh arrivals know things the port doesn't yet. Being on the dock when a specific ship arrives is strategically meaningful in a way that transit hubs mediate through the gate cluster's institutional framework. + +- **Grey economy is structural** — smuggling is older than the Commission here. The spatial infrastructure (hidden coves, pre-registered vessels, cargo manifests that misstate contents) isn't a recent adaptation to Commission oversight — it's how maritime commerce has always worked. The investigation texture is ancient. + +- **DLC expansion as the model** — ocean settings, wilderness settings, specialized orbital installations, specific agricultural planet types — these are exactly the kind of template packs the lead mentioned. The generator pipeline handles them identically. The DLC provides new lore inputs (new heritage roots for maritime cultures, new economic pressure types for seasonal fishing economies, new social site templates for port-specific social dynamics) that the same generator instantiates. + +### 3.5 Variation Axes Unique to Non-Urban Settings + +| Axis | Farmland | Wilderness | Ocean | +|---|---|---|---| +| Temporal rhythm | Seasonal/agricultural | Project/expedition duration | Tidal/shipping schedule | +| Information travel | Community gossip (slow, trust-gated) | Physical proximity only (immediate) | Ships from elsewhere (news arrives in batches) | +| Grey economy type | Diverted yield, black-market seeds | Everything informal by default | Historical smuggling infrastructure | +| Social site type | Community hall, market day, field shelter | Waystation, survey camp, emergency site | Harbormaster, fishing coop, sailors' inn | +| Investigation vector | Land records, water rights, yield discrepancies | "Who was here and why" | Cargo manifests, ship logs, undeclared passengers | +| NPC permanence | High (generational community) | Very low (transient) | Mixed (port workers permanent, sailors transient) | +| Authority presence | Variable (land ownership contested via property law) | Minimal (physical authority only) | Moderate (harbormaster, maritime law) | + +--- + +## Section 4: Insignificant vs. Epicenter — The Spectrum as System + +The lead directive clarifies that "nothing happens here" to "this is the center of everything" is a spectrum the generator must produce intentionally. Let me name the axis formally. + +**Drama Density** is a district/world parameter determined at the Pre-Pipeline stage (above geography). It ranges from: + +- **Zero** — no Tier 1 modules, low economic pressure, stable social fabric. Backwater. Genuine quiet. +- **Low** — one Tier 1 module active, domestic-scale pressure. A world with one interesting thing going on. +- **Medium** — one Tier 1 module + active mundane triangle pressure + economic tension. The transit hub in steady state. +- **High** — multiple Tier 1 modules, contested faction presence, elevated economic pressure. A world in flux. +- **Flashpoint** — multiple modules active, faction conflict, historical disruption, elevated entanglement. Rare. Should feel rare. + +The storyteller (D-005, D-023) uses drama density as a pacing parameter. The generator doesn't determine which module fires — the storyteller does that dynamically. But the generator determines the CAPACITY for drama: whether the world has enough social infrastructure to support a high-drama playthrough. + +A backwater with zero Tier 1 module capacity isn't just quiet — it's GUARANTEED quiet. The storyteller cannot fire a major drama module here because the infrastructure doesn't exist. That's not a limitation. That's a promise to the player. + +**Replayability implication of the spectrum:** On playthrough 1, you choose worlds with high drama density (you want action). On playthrough 2, after the emotional intensity of a flashpoint run, you might deliberately choose the backwater — the quiet life, the mundane social fabric, the gentle pace. That's a completely different game from the same generator. The variation isn't in the generated content — it's in WHICH content you choose to engage with. + +--- + +## Section 5: Updated Variation Axes (Broadened Lens) + +My Round 1 table of 13 axes was investigation-centric and missed several key dimensions. Here's the revised complete table. + +### Fixed Per Seed (Stable Across Character Selection) + +These axes vary between seeds but remain consistent within a single seed regardless of which character you play. The lead confirms: same seed + different character = same world with different lens. + +| Axis | Pipeline Stage | Impact Across All Playstyles | +|---|---|---| +| **World seed (master)** | Game start | Derives all downstream variation deterministically | +| **Drama density** | Pre-pipeline | Backwater → epicenter spectrum; determines storyteller capacity | +| **Cultural ingredient composition** | Pre-pipeline | Social dynamics, trust mechanics, naming, NPC behavior patterns | +| **Heritage root blend** | Pre-pipeline | Specific investigation strategy, social approach required | +| **Faction power gradient** | Pre-pipeline | Political game: who to align with; tycoon game: who controls commerce; investigation game: whose rules to exploit | +| **Economic pressure combination** | Pre-pipeline | Tycoon: investment opportunities; relationship: why NPCs are under stress; political: fault lines to exploit | +| **Historical event seed** | Pre-pipeline | Sets the damage and residue that shapes current state | +| **Terrain type** | Geography | Farmland/ocean/wilderness/station — changes rhythm, social structure, investigation vector | +| **Infrastructure placement** | Infrastructure | Transport nodes, dead zones, Meridian coverage — movement grammar | +| **Tier 1 module pool draw** | Game start | Which conspiracies are active (if any); tycoon/dating sim/political players may interact with or ignore these | +| **NPC 10-axis generation** | Chunk fill | Who the people actually ARE — personality, want, secret, tolerance, relationships | +| **Entanglement assignment** | Chunk fill | Which NPCs are conspiracy-adjacent; not just investigation — affects emotional availability for relationship players too | +| **Triangle configuration** | Chunk fill | Social conflict topology — all playstyles navigate this | +| **Quarter merge patterns** | Block generation | Physical layout, chokepoints, routes, hidden spaces | +| **Evidence/drama node placement** | Chunk fill | Where the interesting things are; varies by playstyle in what counts as "interesting" | +| **NPC tolerance thresholds per seed** | Chunk fill | Social consequences of choices — affects every playstyle | + +### Character-Selection Lens (Same World, Different Perception) + +These don't vary what the world IS — they vary what the player perceives and can do. + +| Lens | What It Opens | What It Closes | +|---|---|---| +| **Smuggler** | Grey economy access, insider relationships, physical routes the system doesn't see | Institutional authority, formal investigation tools, access-tier-gated spaces without workarounds | +| **Detective** | Institutional access, analytical lattice capabilities, formal investigation vectors | Grey economy trust, insider relationships, informal social networks | +| **Future archetypes** | Each archetype opens different game mechanics and social positions on the same world | Everything outside their social/functional domain | + +The replayability here is the *lens*, not the *world*. The world is the same. What you CAN SEE AND DO in it differs radically by character. This is more replayable than two different worlds, because you're discovering new things in the same place — not just a new place. + +--- + +## Section 6: Reconciling With Araminta's Taxonomy + +The flag from Qatux (OQ-8) is correct: my flavor categories and Araminta's empty quarter types use different taxonomies. Here's the reconciliation. + +**The taxonomies are complementary, operating at different levels of abstraction:** + +Araminta's taxonomy answers: **what SHAPE is this empty space, and what are its visual/access properties?** +My taxonomy answers: **what CONTENT occupies this space, and what does it communicate about the social/economic state of the district?** + +They compose, they don't conflict. + +### Unified Quarter Fill Model + +At block generation time (Araminta's step 1-3): assign **spatial type** (Araminta's five categories) +At chunk fill time: assign **flavor content** (Nigel's four categories), constrained by spatial type AND cultural/economic parameters + +| Spatial Type (Araminta) | Compatible Flavor Content (Nigel) | Cultural/Economic Driver | +|---|---|---| +| Open plaza | Settlement indicators (seating, shrines, personal gardens) | High drift stage, community bonds present | +| Open plaza | Faction presence indicators (Commission kiosk, union notice board) | High faction control | +| Service alley | Informal economy indicators (repair shops, vendor carts) | High economic pressure, low faction control | +| Service alley | Economic stress indicators (unauthorized storage, abandoned equipment) | Survival-gap or debt-trap economic pressure | +| Courtyard/garden | Settlement indicators (container gardens, personal shrines) | High community bonds, mature drift | +| Courtyard/garden | Informal economy indicators (informal market, vendor cluster) | Prohibition-economy pressure | +| Vehicle/cargo staging | Informal economy indicators (grey-market stalls at the edge) | Mixed formal/informal district | +| Vehicle/cargo staging | Faction presence indicators (corporate branded infrastructure) | Syndic dominance | +| Structural gap (undeveloped) | Economic stress indicators (shacks, temporary shelters) | Survival-gap pressure, institutional failure | +| Structural gap (undeveloped) | *Empty by design* | Intentional restriction, recent disruption, imminent development | + +**Key rule for the generator:** Structural gap + economic stress indicators = a space that LOOKS uninhabited but probably isn't. Shacks in a structural gap are the grey economy's residential infrastructure. This is where the ring members who don't have official housing end up. That's a mechanically important space for investigation players, and a morally textured space for relationship players (these are people living in the cracks). + +**The L-shape notch reconciliation:** Araminta requires that L-shape notches have a visual/functional explanation. My flavor content categories provide exactly those explanations: +- Service alley + informal economy = the notch is a grey-market side entrance +- Courtyard + settlement indicators = the notch is a communal space that the building grew around +- Structural gap + economic stress = the notch is where an addition was planned but never built + +The generator selects notch explanation from flavor content appropriate to the zone's cultural/economic parameters. Same quarter, different explanation. The L-shape means something different in a labor-solidarity district than in a corporate-controlled one. + +--- + +## Section 7: Miri's Response on Cultural Space Size — Implications + +Miri's answer: the combination space "comfortably exceeds 300 meaningfully distinct societies." My Round 1 estimate of "20 distinct cultural compositions" was wildly conservative. The actual space is enormous. + +This changes my math significantly — and my risk assessment. + +My Round 1 calculation: 300 worlds × 2 characters × 20 cultural compositions = 12,000 distinct games. + +With Miri's actual space: 300 worlds × (characters) × (many hundreds of meaningful cultural compositions) × (seed entropy) = effectively uncountable. + +**But the question that matters for me isn't the size of the space. It's the resolution.** + +Large combination space doesn't help if: +1. Players can't perceive the difference between cultural composition A and composition B +2. The mechanical differences are too subtle to feel distinct +3. The same cultural composition produces the same gameplay even when the seed changes everything else + +Miri's answer addresses this through the `privacy_level` and `trust.building_rate` levers — cultural parameters that translate directly to gameplay timelines and access mechanics. I want to add one more: + +**Economic pressure combination is the highest-resolution variation lever for player experience.** Here's why: + +Miri shows that Sova's `[tight-margin, prohibition-economy]` produces a specific moral texture — economically rational AND ideologically defensible grey economy. Compare to `[survival-gap, prohibition-economy]` — same contraband type but more desperate, less principled. That difference LANDS in player experience because it changes how NPCs feel about what they're doing. The tycoon player encounters different negotiating partners. The relationship player encounters different emotional registers. The investigator encounters different moral stakes in confrontation. + +**My recommendation:** The economic pressure combination should be the highest-weighted variation axis for player-perceived variety, because it changes the EMOTIONAL TEXTURE of the world, not just its mechanics. Two transit hubs with different economic pressure combinations feel like different kinds of humanity, not just different kinds of logistics. + +Miri's additional question (for me): "Are the cultural variation axes sufficient replayability levers, or do we need additional randomization within a cultural type?" + +My answer: **Sufficient for cross-world variety. Insufficient for within-world replayability.** The cultural parameters are fixed per world. Within-world replayability comes from the seed-driven NPC generation, triangle configuration, and entanglement assignment — which vary WITHIN the cultural parameters. A Frost/Salt/Iron culture with entanglement pattern A is a different game from the same culture with entanglement pattern B, even though both feel "Krenn-like." The within-culture variation is the replayability mechanism. The cultural composition is the setting — it's stable, not randomized per run. + +--- + +## Section 8: Seeds Are Solved — What This Means for My Analysis + +The lead confirms: single seed, single world per seed. Same seed + different character = same world with different lens. + +**This is the right call, and here's why from a replayability perspective.** + +The "two keyholes on the same world" principle (D-027) is MORE replayable than two different worlds, because: + +1. **The player can verify what the other character would have seen** — on playthrough 2, the player discovers things they MISSED on playthrough 1. Not just different things — specifically the things that were there all along and invisible to their character. That's a profoundly more satisfying discovery than "this world is different from the last one." + +2. **Character selection becomes a genuine strategic choice** — "I want to see this world through the smuggler's eyes first, then come back as the detective" is a meta-game decision players can make. The world rewards that kind of deliberate multi-playthrough engagement. + +3. **The comparison test works better** — two players comparing notes about the same seed find the world's structure is the same (conspiracy configuration, NPC positions, ring membership) but their experiences are completely different (what they could see, what they could access, what they learned). That's more interesting to compare than two different worlds. + +**What this removes from my variation axes table:** I had "character selection" as a variation axis in Round 1, implying it changes the world. It doesn't — it changes the lens. I've updated the table accordingly (Section 5), treating character selection as a lens layer rather than a generator axis. + +**What this adds:** A responsibility to ensure every generated world is WORTH seeing from multiple character perspectives. A world that only makes sense from the detective's angle fails the generator. This is a validation requirement: can every generated district skeleton produce a valid tycoon game? A valid relationship game? A valid investigation game? If any of those fail, the generator hasn't done its job. + +--- + +## Section 9: Quarter Social Variation — Responding to Ozzie's OQ-3 + +Ozzie asks whether the flavor structure assignment in unclaimed quarters has **downstream social consequences** — does a garden quarter mean something different about who lives nearby versus a shack quarter? + +My answer: **Yes, and the generator must make this explicit.** + +The current framing (Round 1) treated flavor structures as ambient content — things that fill space and communicate setting. That's necessary but insufficient. Ozzie is right that if quarters are just aesthetic choices, players will see through them on the second station. + +**Proposed mechanism: Flavor type → NPC pattern weight modifier** + +| Flavor type | NPC pattern weight shift | +|---|---| +| Market stall cluster | +HANDLER (trade coordinator), +CIVILIAN (customers) | +| Commission kiosk | +SYSTEM (enforcement), -HANDLER (less informal trade) | +| Container garden | +ANCHOR (community pillars), +NOBODY (background domestics) | +| Shack cluster | +CATALYST (people under pressure), +REMNANT (people left behind) | +| Union hall | +SYSTEM (organized labor), +WITNESS (institutional memory) | +| Corporate infrastructure | +SYSTEM (corporate agents), -ANCHOR (less community cohesion) | + +This creates the causal chain Ozzie wants: **physical space is the consequence of social forces, and social forces adjust to the physical space that represents them.** A shack cluster in a structural gap quarter doesn't just LOOK like poverty — it attracts NPC patterns consistent with people living under economic stress. When the player investigates the shack quarter, the NPCs they find have different motivations and different secrets than the NPCs in the garden courtyard two blocks over. + +**This is what makes the quarter system produce social variation, not just visual variation.** And it's where Ozzie's "questions encoded in space" become literal game mechanics. + +--- + +## Summary: What Changed from Round 1 + +1. **The investigation lens was too narrow.** The generator must produce distinct replayable experiences for tycoon, relationship, political, and investigation playstyles simultaneously. Every generated world must be valid for all of them. + +2. **The backwater is a first-class game state, not a gap in the drama density.** Nothing-happens-here worlds are the contrast that makes everything-happens-here worlds meaningful. The generator must produce them intentionally. + +3. **Non-urban terrain types (farmland, wilderness, ocean) are massive unexplored variation axes.** Different temporal rhythms, different investigation vectors, different social site types, different grey economy structures. DLC template packs are exactly the right expansion model. + +4. **The drama density spectrum** (backwater → epicenter) is a new named generator axis that I missed in Round 1. + +5. **Araminta's spatial taxonomy and my flavor taxonomy are complementary**, operating at different levels. Spatial type is decided at block generation. Flavor content is decided at chunk fill, constrained by spatial type and cultural/economic parameters. + +6. **Cultural space is vast — the critical resolution variable is economic pressure combination**, which produces the highest player-perceived variety by changing the emotional texture of the world. + +7. **Single seed is right.** Same world, different lens. Character selection reveals what was always there. + +8. **Quarter flavor types must produce downstream NPC pattern consequences**, not just visual variety. The shack quarter and the garden quarter attract different NPC patterns because they're the physical expression of different social forces. diff --git a/docs/workshops/generator-architecture/nigel-round3.md b/docs/workshops/generator-architecture/nigel-round3.md new file mode 100644 index 000000000..9031bbca5 --- /dev/null +++ b/docs/workshops/generator-architecture/nigel-round3.md @@ -0,0 +1,324 @@ +# Generator Architecture Workshop — Round 3: Nigel (Replayability & Procedural Generation) + +**Date:** 2026-02-27 +**Role:** Replayability Advocate +**Task:** Assassination replayability. Dynamic world modification. Mobile chunks. Playstyle mismatch as discovery mechanic. Reconcile SignificanceTier/ComplexityTier/DramaDensity. + +--- + +## Section 1: Assassination Replayability + +### What Makes Assassination Gameplay Replayable + +Assassination is constraint satisfaction under time pressure: reach the target, eliminate, escape unidentified. For this to replay differently, the constraints must be different each run. Specifically: different sightlines, different crowd patterns, different escape routes, different timing windows. Let me name what the generator actually varies. + +### Sightline Geometry Per Seed + +The sub-chunk quarter system produces different physical geometries per seed. Every quarter merge pattern decision propagates to LOS. The same social site template — say, the bar — can appear in four different physical configurations depending on which quarters merged. In one seed, the target's regular table is adjacent to the main entrance, exposed to the door from every approach. In another, the same NPC routine places them in a booth with a single sightline gap, approachable from the service corridor. The generator doesn't know assassins will be using this information. It produces geometry from social and economic parameters. The assassin reads that geometry as approach planning. + +**The generator's guarantee to assassination gameplay:** sightline geometry must differ meaningfully between seeds of the same district type. This is already guaranteed if quarter merge patterns are seeded differently — but it requires that the spatial guarantee archetypes (particularly Traffic Chokepoints and Informal Zones) not always appear in the same relative positions. If the Traffic Chokepoint is always northwest of the Social Hub, every assassination approach maps to the same template. + +**Requirement: archetype placement must vary in angular position across seeds, not just in distance from center.** This is currently unspecified in the generator design. I'm flagging it as a hard replayability requirement for the action-gameplay pillar. + +### Crowd Pattern Variation Per Seed + +NPC schedules are seeded from the 10-axis generation + D-031 day phases. The crowd patterns that provide cover or represent hazard vary per seed in two ways: + +1. **Who is present**: The entanglement configuration determines which NPCs have divided attention, which are focused on their routine, and which are actively surveilling. In one seed, the logistics shift supervisor is entangled and distracted. In another, they're clean and paying full attention to the dock floor. + +2. **When they're present**: Day-phase scheduling produces different density windows. The same space that's crowded enough to provide movement cover during shift transition is sparse and exposed during maintenance hours. The assassination timing window depends on which day-phase alignment the target's routine creates. + +Both vary per seed. The assassin who memorized "the third hour of the evening shift is when the target is isolated" is working with playthrough-specific knowledge that won't transfer. + +### Escape Routes Per Seed + +Escape routes are the reverse of approach routes — they're the spaces that provide cover FROM the chokepoints. Quarter merge patterns determine where service alleys exist, where structural gaps are accessible, which back-facing edges of blocks have maintenance access. The escape geography is the backside of the same quarter system that produces the approach geometry. + +**Structural variety guarantee**: For assassination gameplay to replay, the escape topology must be substantially different between seeds. Not just cosmetically different — the number of viable exits from a given elimination zone must vary (sometimes two exits, sometimes one, sometimes a path that requires prior access arrangement). This creates genuinely different risk profiles per playthrough, not just different aesthetics. + +### Timing Windows Per Seed + +The target's routine is seeded from NPC generation. Same role, different schedule. A senior official who visits the commissary daily in one seed visits weekly in another. The window in which they're accessible in the informal zone (Gestalt's Archetype 2) — the space without institutional coverage — is different per run. + +Combined with the day-phase system (D-031) and the crowd pattern variation above, this means the **timing window arithmetic is unique per seed**. There's no universal "best time to move on this type of target." The assassin has to observe the specific NPC's specific routine in this specific seed. + +### The Grid Breathability Question (OQ-R3-A) Is Critical for Assassination + +Ozzie raises whether the block grid can rotate or breathe — whether streets can curve, whether adjacent districts can have different orientations. For most playstyles, this is primarily an aesthetic concern. For assassination gameplay, it's mechanically load-bearing. + +If the grid is always four rectilinear quadrants with perpendicular streets, an experienced player can overlay a mental template onto any new district and immediately identify likely approach corridors, chokepoints, and escape vectors. Second Station Syndrome for assassination is: "I know where the service corridor will be before I've explored." The grid must be unpredictable enough that physical reconnaissance is required each run — even in familiar district types. + +I'm not the right person to specify HOW the grid breathes (that's Tyre and Araminta). But I need the outcome: **the spatial skeleton of a district must not be predictable from district type alone.** Same district type, significantly different spatial skeleton. The quarter system handles fill variation. The block planning stage must handle skeleton variation. + +--- + +## Section 2: Dynamic World Modification as a Replayability Tool + +### Is World Mutation Good? YES. + +A gas explosion changes a district. This is excellent for replayability. Here's why. + +The generator produces a baseline world from a seed. Every run of the same seed starts identical. Replayability within a seed comes from one thing: **playthrough divergence from the baseline**. Destruction events are the highest-leverage divergence mechanism because they change the physical world, not just the social world. + +Two playthroughs of the same seed: +- Playthrough 1: The maintenance corridor in block 7 exists. The player uses it as an escape route. +- Playthrough 2: A gas explosion (storyteller-triggered, different timing) blocked that corridor six hours before the player arrived. The escape route they planned doesn't exist. + +These are genuinely different games. Not because the seed differed — because the simulation produced different outcomes. + +### The Architectural Foundation Is Already There + +Tyre's Phase 2 architecture already handles this correctly: ChunkData is cached after generation and written to save. A modified chunk (destroyed corridor, collapsed wall, fire-damaged room) is saved as modified ChunkData. The Phase 1 PreparedDistrict doesn't change — the social configuration, NPC rosters, and spatial skeleton remain the seed-derived baseline. Phase 2's tile data diverges from that baseline as events modify it. + +This means destruction events don't require special generator support — they're modifications to existing ChunkData. The generator's job is to produce the *baseline*. The simulation's job is to track modifications. Save/load preserves the current state. + +### Player-Caused Destruction as Permanent Private Knowledge + +When the player blows a hole in a wall, they create an access route that exists nowhere in the generator's output. This is a form of **private geographic knowledge** — the player knows this route exists; most NPCs don't (until the Commission investigates the structural damage). + +This is high-variance replayability because: +1. On playthrough 2 (same seed), the hole doesn't exist until the player creates it again — or doesn't +2. The decision of WHEN to create the destruction affects downstream events differently each run +3. Player-authored geography creates personalized playthrough states that feel earned + +**D-051 principle**: "every placed tile is someone's decision." When the player places destruction, they're authoring their world. The simulation records and respects that. + +### The Storyteller's Destruction Grammar + +The storyteller (D-005, D-023) can USE planned destruction as a narrative instrument. The mechanism: + +1. **Pre-seeded structural vulnerabilities**: The generator seeds certain infrastructure with fragility tags (aging pipes, overloaded power conduits, unstable load-bearing configurations). These are invisible to the player unless discovered through investigation/engineering observation. + +2. **Storyteller activation**: When narrative tension reaches a threshold, the storyteller can "activate" a fragility — not by scripting an explosion, but by seeding the simulation conditions where explosion becomes probable. An aging pressure seal under increased freight load, with a maintenance NPC distracted by a social conflict, becomes a plausible accident. + +3. **Timing for dramatic effect**: The storyteller knows player position, active investigation threads, and dramatic potential. A gas explosion that occurs while the player is in a nearby space creates a "you barely missed it" moment. One that occurs WHILE the player is accessing the maintenance corridor creates maximum tension. + +This is not scripted drama. It's the simulation producing dramatic outcomes from realistic conditions, with the storyteller nudging the probability. The destruction is CAUSED (Ozzie's requirement), not RANDOM. + +### Risk: Destruction Must Remain Extraordinary + +If destruction is too common, it becomes a mechanic rather than an event. The generator's fragility seeding must be sparse. A district where structural failures happen constantly loses the dramatic weight of destruction. The player should experience world modification as RARE and MEMORABLE — each instance a unique playthrough marker. + +**Recommendation**: Fragility tags should be present in <5% of maintenance/infrastructure chunks per district. The storyteller should activate them only when dramatic conditions make the activation feel earned, not as a routine pacing tool. + +--- + +## Section 3: Mobile Chunks — Trains, Ships, Traincars + +### Why Mobile Environments Are Maximum Replayability + +I want to make this case strongly before we even get to architecture: mobile environments are some of the highest-variance gameplay spaces the generator can produce, and they don't require the generator to work particularly hard. The variance comes from the SITUATION, not the space. + +A ship voyage or train journey is a social pressure cooker because: +1. **Temporal constraint**: The journey ends. Everything must happen before arrival. +2. **Social confinement**: These specific NPCs are the whole world for the duration. No walking away, no coming back tomorrow. +3. **Information compression**: Normal investigation can be deferred — talk to that NPC tomorrow, check the archive next week. On a vessel, you have the journey and that's it. + +These structural properties produce high replayability without the generator doing anything special. The variation comes from: + +- **Who's on this voyage** (passenger manifest seeding) +- **What the passengers know** (entanglement configuration — one seed puts a ring operative on the same ship as the player's investigation target; another doesn't) +- **What day-phase the journey occupies** (shared mealtimes, shift changes, the specific windows when certain conversations are possible) +- **What external events occur during transit** (weather, delay, emergency — storyteller-seeded) + +### Vessel Architecture — The Instanced District Model + +I want to address OQ-R3-D directly. Rather than pure entity-carried chunks (tiles moving in coordinate space, which Miri flags as architecturally complex), I propose vessels as **instanced districts**: + +**A vessel is a district instance that is:** +- Generated at journey-start (Phase 2 runs on booking + departure) +- Loaded as a normal district (player enters, chunks generate, NPCs spawn) +- Visually situated through client-side animation (windows show terrain passing; the experience of movement is rendered without the coordinate actually changing) +- Terminated at arrival (instance unloads, player transfers to destination district) + +**What this gives us:** +- Full architectural compatibility with D-094 hierarchy (same chunk/block/district model) +- Full NPC simulation (schedules, relationships, knowledge graph — all work identically) +- No coordinate-system complexity (tiles don't move) +- Visual effect of travel through client rendering (Godot renders a parallax-scrolling background in window tiles) + +**What this requires:** +- The vessel template is authored like a D-025 social site — a fixed spatial layout, NPC role slots, triangle configurations +- The passenger manifest is seeded at journey-start from the route + available NPC pool at the departure location +- The instance has a lifespan (arrival time), which the storyteller can modify (delay, emergency stop, diversion) + +**The temporal constraint as a replayability mechanic**: The player can see the arrival countdown (diegetically: through their neural insert navigation system). They know they have six hours. What they accomplish in those six hours depends on: +- Who's aboard (seeded) +- Which conversations become possible as trust builds during the journey (relationship mechanics within the instance) +- What the storyteller drops into the voyage (external events that create pressure or opportunity) + +Different seed, different passenger manifest, different dramatic possibilities. The same route (Sova → destination) is never the same journey twice. + +### The Dating Sim on the Ship + +I want to call out something specific here because it highlights the non-investigation playstyle potential: **the ship is the premier dating sim environment**. + +In a normal district, relationship-building requires repeated visits over time. The bar exists, the player can go there any evening. The relationship unfolds at the pace of repeated encounters. On a ship, **proximity is enforced**. You share meals. You're in adjacent cabins. You're both stuck here for six hours. + +Relationships that would take a week of deliberate effort in a district form (or break) in a single voyage. The player who wants to understand an NPC deeply has an unparalleled opportunity during transit. And the NPC who would normally take time to warm up may, under the specific social pressure of a confined space, reveal things they wouldn't in a normal encounter. + +This is the "something happens on a ship that wouldn't happen in a bar" quality. The generator produces this not through special content authoring but through the structural situation the vessel instance creates. + +--- + +## Section 4: Playstyle Mismatch as a Discovery Mechanic + +### The Mismatch Is Good Design + +I argued in Round 2 that the generator should NOT try to balance all playstyles equally within every district. Round 3 lets me be more specific: **playstyle mismatch is itself meaningful information about the world**. + +When a tycoon player arrives at a farming settlement and finds almost nothing to trade, they've learned something real: this community isn't economically integrated into the wider network. It's self-sufficient, or it's isolated, or it's poor. The absence of tycoon opportunity is a worldbuilding signal, not a generator failure. + +Similarly: an investigation player who arrives in a wealthy resort district and can't find a conspiracy isn't experiencing a failed district — they're experiencing a world where affluence and social control have suppressed the visible signals of crime. The investigation is HARDER, not absent. That difficulty is the discovery. + +### How Players Discover Playstyle Fit + +The player doesn't know in advance which playstyle a world favors. That knowledge is discovered through play — specifically, through **early engagement friction**: + +- Tycoon player arrives at farming settlement, looks for the market, finds it's a seasonal thing happening once a month, not now → discovers this isn't a trading world, it's a community world +- Investigation player arrives at resort world, can't find manifest discrepancies, can't find information gaps → discovers the ring here operates differently (social extortion, not cargo smuggling) and requires a completely different investigation approach +- Dating sim player arrives at a logistics hub during a busy freight rotation, finds all NPCs too busy and scheduled for casual social engagement → discovers this isn't a leisurely social world, it runs on work discipline + +The discovery is: **what kind of world is this?** That's a meaningful question the generator's outputs answer through gameplay friction, not text. + +### Playstyle Drift as a Play Experience + +A tycoon player who gets drawn into the social drama of a farming settlement because the NPC relationships were genuinely interesting isn't a lost tycoon — they're having an authentic experience. The simulation produced something compelling and they responded to it. Their playstyle DRIFTED. + +This is one of the most valuable things the generator can produce: **the conditions for a player to surprise themselves**. They came for the market, they stayed for the people. That's a story the player will tell. It's emergent from systems that don't know what the player intended. + +**Generator requirement**: The only hard requirement is that every Full-complexity district offers ENTRY POINTS for all playstyles via Gestalt's 7 spatial archetypes. Not equal depth — entry points. The tycoon player who drifted into dating sim mode can drift back if they choose. The world doesn't lock them in. + +The Moderate and Minimal complexity districts (non-urban, backwater, passage nodes) don't need to guarantee all 7 archetypes. They offer what they offer. The player learns to read complexity tier through experience — another discovery mechanic. + +### The "Best Place for X" Meta-Game + +Across 300 worlds, players will develop opinions about which world types are best for which playstyle. The farming settlement is the place for relationships. The logistics hub is the place for investigation. The capital is the place for political drama. + +This meta-knowledge is GOOD. It gives experienced players meaningful choices about where to go. It's the exploration game — not "what's in this world" (you'll find that out) but "what do I want this play session to be about?" Different players going to different worlds for different reasons is the game working correctly. + +--- + +## Section 5: Reconciling SignificanceTier / ComplexityTier / DramaDensity + +Qatux correctly flags these three as overlapping concepts that need reconciliation before the Pre-Pipeline stage can be formally specified. Here is my proposed unified model. + +### What Each Concept Is Actually Measuring + +**Gestalt's SignificanceTier** (Center-stage / Regional / Backwater / Waypoint / Insignificant): +- Measures *relative network importance* — connectivity, political weight, historical significance +- This is a STRUCTURAL parameter: how does this world relate to others? +- Set at system generation, doesn't change during a playthrough + +**Tyre's ComplexityTier** (Full / Moderate / Minimal / Empty): +- Measures *generator output depth* — how many guarantees apply? How many templates get instantiated? +- This is a CONTENT DEPTH parameter: how much does the generator produce here? +- Set during Phase 1, doesn't change during a playthrough + +**Nigel's DramaDensity** (Zero / Low / Medium / High / Flashpoint): +- Measures *active narrative intensity* — how many Tier 1 modules are active right now? +- This is a DYNAMIC parameter: it can change during play as the storyteller activates modules +- Set initially by the generator; the storyteller has write access + +### Why Two of These Can Collapse + +SignificanceTier and ComplexityTier are correlated but not identical — and when they're not identical, the non-obvious case is the most interesting one. + +A Waypoint (low network significance) almost always gets Minimal or Empty complexity. That's fine — a transit node with nothing permanent is correctly sparse. + +But a Backwater (low network significance) can be EITHER Full complexity (a dense, rich, human community that just doesn't matter to the wider galaxy) OR Minimal complexity (genuinely sparse, few inhabitants, passing through). The difference between these two backwater types is enormous for gameplay — one produces a dating sim/political drama rich environment; the other is genuinely empty. + +**The key insight: network significance and interior richness are independent.** A Backwater can have Full interior complexity. An Epicenter can be surprisingly sparse if it's primarily a transit hub rather than a residential community. + +Therefore, we need BOTH axes — but they can be defined more cleanly: + +### Proposed Two-Parameter Model + +**Parameter 1: WorldTier** (static, set at system generation, captures network significance) +- `Epicenter` — maximum connectivity, major faction presence, historically significant +- `Regional` — meaningful connectivity, notable faction presence, relevant to the wider network +- `Passage` — transit-relevant primarily, light faction footprint, functionally important but not socially deep +- `Backwater` — low external connectivity, weak external faction presence, self-contained +- `Waypoint` — minimal or no social complexity, geography/transit function only + +**Parameter 2: ComplexityTier** (static, set at Phase 1, captures generator output depth) +- `Full` — all 7 spatial archetypes guaranteed, complete NPC population, all four playstyle entry points +- `Moderate` — subset of archetypes (4+), meaningful NPC population, 2-3 playstyle entry points +- `Minimal` — 1-2 archetypes, sparse NPC population, 1 primary playstyle +- `Empty` — geography only, no permanent social structure, no NPC simulation + +These replace all three competing concepts with two orthogonal parameters. WorldTier answers "where does this world sit in the network?" ComplexityTier answers "how deep is the generator output?" + +**The Backwater case resolved:** +- `Backwater + Full` = a dense, isolated community rich with human drama. The information asymmetry challenge inverts (Miri's insight): you can't be anonymous, everyone knows your name within hours, the conspiracy is intimate. This is a completely different game from a logistics hub. +- `Backwater + Minimal` = a genuinely sparse settlement, a homestead, a research outpost. A few NPCs, minimal social fabric, brief engagement. + +**The Epicenter case:** +- `Epicenter + Full` = The center of everything, complex, contested, rich with all four playstyle opportunities +- `Epicenter + Moderate` = A junction node — important to the network, but the social life is shallow (high transit, low permanence). The tycoon game is excellent here; the dating sim is difficult. + +### Parameter 3: DramaDensity (Dynamic, Storyteller-Controlled) + +DramaDensity is fundamentally different from the first two parameters because it changes during play. It can't be collapsed into a static parameter. + +**DramaDensity**: Zero / Low / Medium / High / Flashpoint +- Set initially by the generator from the seed (how many Tier 1 modules are positioned to be active in this world) +- WorldTier constrains the achievable range (a Waypoint can't sustain High; a Waypoint + Empty can't sustain anything above Zero) +- The storyteller has write access and can elevate or suppress DramaDensity based on pacing needs +- **The false backwater mechanism**: A world that starts at Zero DramaDensity can be elevated to Medium by the storyteller when the player's actions create the conditions for drama to become plausible. The generator seeded the structural capacity; the storyteller activates it. + +### Unified Model Summary + +| Concept | Parameter | Type | Set When | Who Owns It | +|---|---|---|---|---| +| Network importance / significance | `WorldTier` | Static | System generation | Generator | +| Generator output depth | `ComplexityTier` | Static | Phase 1 | Generator | +| Active narrative intensity | `DramaDensity` | Dynamic | Phase 1 initial; storyteller modifies | Generator initial; storyteller ongoing | + +**The DistrictSkeleton** should carry all three: `world_tier: WorldTier`, `complexity_tier: ComplexityTier`, `drama_density: DramaDensity`. The first two are immutable after Phase 1. The third is a live field the storyteller system updates. + +**Constraint relationship**: `WorldTier` constrains the maximum achievable `ComplexityTier` (an Epicenter can be Full; a Waypoint cannot). `ComplexityTier` constrains the maximum achievable `DramaDensity` (Full complexity can sustain Flashpoint; Empty cannot sustain above Zero). + +### Why This Matters for Replayability + +The three-parameter model enables a player-facing experience where worlds feel categorically different, not just quantitatively different. A Backwater/Full/Zero world is a genuinely different type of experience from a Regional/Full/High world — not just the same template with more or fewer drama events. + +The DramaDensity being dynamic means: the world the player visits on playthrough 1 may be a different drama density on playthrough 2 (either because the storyteller applies different pacing, or because the player's actions in this playthrough create different preconditions for drama activation). The same World/Complexity configuration produces different lived experiences across playthroughs via the dynamic drama layer. + +--- + +## Section 6: Cross-Cutting Observations for Round 3 Convergence + +### On OQ-R3-B (Triangle Purpose Taxonomy) + +Gestalt proposes `triangle_purpose: TrianglePurpose` (investigation/economic/political/social) on triangle nodes. From a replayability perspective: YES, this is essential and low-complexity to add. + +If all triangles activate regardless of playstyle context, the player constantly has access to all dramatic possibilities. That reduces tension. A tycoon player who doesn't care about the conspiracy shouldn't have the conspiracy's triangles pressing on them unless they engage. Triangle purpose tags let the storyteller activate relevant triangles based on what the player is actually doing — not scripting the drama, but surfacing the drama that's relevant to the player's current engagement pattern. + +### On OQ-R3-C (Maritime/Wilderness Informal Zone) + +For wilderness: the informal zone is anywhere away from the camp/waystation. The wilderness itself is the informal zone — the entire geography is low-coverage, low-surveillance, low-NPC-density. There's no "finding the maintenance corridor" equivalent; the wilderness is all maintenance corridor. + +For maritime: hidden coves, sea caves, below-deck storage compartments, vessels anchored in fog. The terrain provides the informal zone naturally. + +Gestalt's `terrain_informal_zone` concept is correct. The generator doesn't need to deliberately place an informal zone in non-urban settings — the terrain type produces it automatically. The generator just needs to know that the wilderness biome flag satisfies the informal zone guarantee without explicit placement. + +### On the Grid Breathability Question (OQ-R3-A) + +I've argued this is load-bearing for action gameplay. My replayability position: the player must be required to do physical reconnaissance on every new location, regardless of how many similar locations they've visited. If the grid is predictable, reconnaissance becomes template-matching and stops being exploration. + +I can't specify the technical solution (that's Tyre and Araminta). But I can specify the replayability requirement: **from a player-facing perspective, the physical skeleton of a Full-complexity district must not be recognizable as the same class of district until the player has explored it**. Whether that's achieved through grid rotation, non-rectilinear blocks, visual technique, or some combination — the output must defeat structural pattern-matching. + +--- + +## Summary: Round 3 Positions + +1. **Assassination replayability**: sightline variety (quarter geometry), crowd pattern variation (seeded schedules), escape topology variation (quarter backside), timing window variation (NPC routine seeding). Hard requirement: archetype placement must vary in ANGULAR position across seeds, not just distance from center. Grid breathability is mechanically load-bearing, not just aesthetic. + +2. **Dynamic world modification**: excellent for replayability. Baseline → playthrough divergence is the key mechanism. Storyteller-activated fragilities produce CAUSED destruction (Ozzie's requirement). Player-caused destruction creates private geographic knowledge. ChunkData mutation already supported by Tyre's Phase 2 architecture. + +3. **Mobile chunks**: Instanced district model (vessels are district instances with a lifespan, not entity-carried tiles). Passenger manifest seeded at journey-start. The vessel is the premier environment for temporal-constraint and dating-sim gameplay. The window/visual movement is a client rendering concern, not a simulation concern. + +4. **Playstyle mismatch is good**: it's discovery, not failure. Friction reveals what kind of world this is. The only hard requirement is entry points (7 archetypes) for Full-complexity; not equal depth. Playstyle drift (following what's compelling regardless of intent) is the best outcome the generator can produce. + +5. **Unified three-parameter model**: `WorldTier` (static, network significance), `ComplexityTier` (static, generator output depth), `DramaDensity` (dynamic, storyteller-modified). Collapses three competing concepts into two static + one dynamic. WorldTier constrains ComplexityTier; ComplexityTier constrains DramaDensity ceiling. The DistrictSkeleton carries all three. diff --git a/docs/workshops/generator-architecture/nigel-round4.md b/docs/workshops/generator-architecture/nigel-round4.md new file mode 100644 index 000000000..530986182 --- /dev/null +++ b/docs/workshops/generator-architecture/nigel-round4.md @@ -0,0 +1,369 @@ +# Generator Architecture Workshop — Round 4: Nigel (Replayability & Procedural Generation) + +**Date:** 2026-02-27 +**Role:** Replayability Advocate +**Task:** OQ-R4-A (MobileChunk replayability), OQ-R4-E (One NPC / Five Lenses), OQ-R4-F (soft re-generation coherence), final sign-off on 12 D-ready items. + +--- + +## OQ-R4-A: Vessel Architecture — Accepting MobileChunk, Specifying Replayability Requirements + +### Accepting the Architecture; Moving Forward + +The lead has settled vessel architecture as entity-carried `MobileChunk`. My instanced district model is retired. I want to briefly record why I don't fight this: the persistent world-entity model offers one replayability property my model couldn't — **vessels have a history**. A ship that has docked at fifteen ports, been used to smuggle contraband twice, and hosted an assassination attempt on voyage seven is a RICHER object than a vessel that only exists during voyages. The entity-carried model enables vessels to accumulate simulation state across time. That is, on reflection, better for the game I want to make. + +What follows is my full specification of what the MobileChunk model needs from a replayability perspective. + +--- + +### The Stage and the Cast + +The core conceptual frame for vessel replayability: **the vessel is a stage; the manifest is the cast**. + +The stage (ChunkData interior) stays the same across voyages. The same ship has the same corridors, the same cabins, the same social spaces. This is correct and good — familiarity with the ship is earned knowledge that players can exploit on subsequent voyages. A player who has ridden the Tide-loop cargo hauler before knows where the service access is, knows which cabin is near the galley, knows the captain's usual table. That knowledge is capital they've built. The replayability isn't in rediscovering the layout; it's in who's aboard and what they know. + +The cast (passenger manifest) changes every voyage. The same ship, the same route, but entirely different dramatic potential. + +--- + +### Manifest Seeding Strategy + +Every voyage produces a new passenger manifest. The seed formula: + +``` +voyage_manifest_seed = derive_seed(master_seed, "vessel_manifest", vessel_entity_id, voyage_index) +``` + +`voyage_index` increments on every departure, not on every calendar day. A ship that makes three voyages on the same in-game day has three distinct manifests. + +**Manifest composition:** + +The manifest has two zones: +- **Fixed slots** — crew. Always present, same personnel per voyage. The cook is the cook. The first mate is the first mate. These NPCs are generated from the vessel's generation seed (not the voyage seed) and persist across all voyages. They develop relationships with repeat passengers over time. +- **Variable slots** — passengers. Drawn fresh per voyage from the eligible NPC pool at the departure location at the time of departure. + +**Passenger eligibility criteria** (evaluated at journey-start): +1. NPC is present at the departure location at departure time +2. NPC has a plausible purpose for this route (home port on the other end, active relationship at the destination, commercial reason, assigned by faction, fleeing a situation) +3. NPC satisfies berth class (if the ship has class-stratified cabins, as Miri's `BoundedLinear` model specifies) +4. NPC is not in a simulation state that prevents travel (hospitalized, under house arrest, actively mid-scene) + +From the eligible pool, passengers are selected by `voyage_manifest_seed`. This means: + +**Voyage A and Voyage B of the same ship, same route, drawn from overlapping NPC pools** — different manifests. The eligible pool at departure time changes between voyages because the simulation has run. NPCs complete trips, return home, get tied up elsewhere. The manifest isn't just a random draw from a static pool; it's a draw from whatever pool actually exists at departure, seeded deterministically. + +--- + +### What Makes Voyage 5 Different from Voyage 1? + +Five independent sources of variation across voyages of the same ship: + +**1. Manifest composition** +The passengers change every voyage. The entanglement configuration among those passengers — who's connected to whom, who's being watched, who's carrying knowledge the player wants — is seeded from the voyage manifest seed. Voyage 1: a smuggling ring operative sharing the ship with their handler, both unaware the player is investigating the same ring. Voyage 5: entirely different cast, different dramatic potential. + +**2. NPC knowledge state drift** +Even repeat passengers (NPCs who've traveled this route before) have different knowledge states on voyage 5 than voyage 1. The simulation has run. A NPC who on voyage 1 was unaware of a conspiracy is on voyage 5 the primary witness to it. Same NPC, same 10-axis generation, different information inventory because the world has changed. + +**3. Storyteller state** +DramaDensity can vary per voyage. A route that was Low-density when the player first traveled it becomes a Flashpoint voyage when the storyteller has activated drama modules that involve NPCs aboard this ship. + +**4. In-transit events** +The storyteller can seed in-transit events (delays, emergencies, unexpected dockings) from a per-voyage event seed. Voyage 3: smooth crossing. Voyage 5: unexpected stop at an unscheduled port while the Commission investigates a distress signal. The player who knows this route intimately has never encountered THIS version of it. + +**5. Crew relationship state** +The crew accumulates relationship state across voyages. The cook who was neutral toward the player on voyage 1 has warmed (or soured) by voyage 5 depending on simulation events. The crew provides persistent social continuity that makes repeat voyages feel like returning to a place with memory, not resetting to a blank state. + +--- + +### The Temporal Constraint Mechanism + +The arrival deadline is the primary dramatic engine aboard a vessel. The player can see it: their neural insert displays arrival time. This creates guaranteed temporal pressure without the generator engineering it. + +For replayability: the temporal constraint means the player cannot do everything every voyage. On a six-hour crossing, there are perhaps four meaningful conversations possible, three explorations of the ship's spaces, and one incident. The player must choose. Different voyages, different choices, different outcomes — even with the same cast. + +The storyteller can modify arrival time (delay, emergency, diversion). A voyage that was supposed to be six hours becomes nine. Three additional hours of enforced proximity. The NPC who had almost opened up, who was one more meal away from revealing what they know, now gets that meal. This is the highest-leverage storyteller tool aboard a vessel: not changing who's present, but changing how long they're all stuck together. + +--- + +### Replayability Requirements for MobileChunk (For the D-Record) + +Formalizing as verifiable requirements: + +**R-V-1: Voyage manifest seeded per departure, not per vessel.** +The same ship on different voyages must have meaningfully different passenger lists. The guarantee: at least N passengers must differ between adjacent voyages (N = floor(variable_slots × 0.5) — at least half the variable slots turn over). + +**R-V-2: Crew is persistent, passengers are variable.** +Crew NPCs persist across all voyages of the same vessel. This creates historical continuity. Passenger slots are refilled from the eligible pool at each departure. + +**R-V-3: In-transit events are voyage-seeded, not vessel-seeded.** +The same ship should not always produce the same incidents. Each voyage gets its own event seed, derived from the voyage manifest seed. + +**R-V-4: Arrival time is storyteller-modifiable.** +The temporal constraint is a storyteller instrument. The deadline can be extended (delay, diversion) or shortened (emergency early docking) based on narrative needs. + +**R-V-5: Vessel interior does NOT re-generate per voyage.** +The interior ChunkData is fixed at vessel generation time. Players who learn the ship's layout have earned that knowledge. The replayability is in the cast and the events, not in rediscovering the stage. + +**R-V-6: Vessel carries ChunkMutations for accumulated damage.** +A vessel that has been boarded, damaged, or modified during a voyage carries those modifications forward. The hole the player blasted through the bulkhead on voyage 3 is still there on voyage 5 unless repaired. Vessels accumulate history. + +--- + +## OQ-R4-E: "One NPC, Five Lenses" — Replayability Check + +### The Question + +Miri demonstrates that one sufficiently complex NPC can provide all five playstyle entry hooks simultaneously (investigation anchor, tycoon economic chokepoint, dating sim social presence, political drama nexus, assassination latent hook). The question: does this create a district that plays the same every time? + +### Answering the Direct Question + +**Within a single seed**: YES, the same complex NPC provides the same hooks every visit. The investigation hook is always the same anomaly. The economic chokepoint they control is always the same resource. The same seed produces the same NPC with the same 10-axis profile. The district will feel "solved" once the player understands the NPC. + +But this is the correct behavior. Same seed = same world. The point of a single complex NPC is not variation within a seed — it's that this is a place where ONE person MATTERS. In a small community, that's realism: there is a person here whose story is the story of this place. Exhausting that story in one thorough visit is accurate, not a design failure. + +**Across seeds**: FULLY VARIABLE. Different seed = different NPC. Different anomaly. Different economic stranglehold. Different romantic archetype. Different political position. Different reason to be in a quiet backwater. The one-NPC minimal district provides maximum seed-to-seed variation because the entire dramatic content of the world is packed into one NPC, and that NPC is generated fresh per seed. + +### But One NPC Is Not Enough for Intra-Seed Variation + +The instinct to require 2-3 NPCs for minimal districts is correct, but the REASON is not replayability across seeds. The reason is **emergent drama requires relationships**. + +Gestalt's minimum functional triangle is three nodes. One NPC cannot be in a triangle alone. Without relationships between NPCs, there are no triangles. Without triangles, the drama is a monologue — one person's story, fully contained within themselves. A monologue is finite. Once you've heard it, it's heard. + +Two NPCs in tension produce a story. Three NPCs produce emergent behavior that none of them individually contains. The triangle is where unpredictable outcomes emerge from predictable NPC behaviors. + +### The Minimum for Meaningful Replayability Within a Seed + +**For a minimal-complexity insignificant district:** + +- **1 NPC**: A character study. High seed-to-seed variation. Zero intra-seed variation after the first thorough visit. Not replayable. +- **2 NPCs**: A relationship. Intra-seed variation from relationship dynamics (trust building/breaking, information transfer between them over time). Limited emergent behavior. +- **3 NPCs**: A triangle. Intra-seed variation from triangle dynamics, shifting alliances, cascade effects when one node changes state. This is the minimum for genuine emergent narrative. + +**My recommendation**: The minimum NPC count for a district to be replayable within a seed is **3 — one functional triangle**. For minimal-complexity districts, those 3 NPCs can each be simpler than a Full-complexity NPC (less information inventory, fewer entanglements, narrower routine), but there must be 3. + +### Does the 10-Axis Model Support One NPC Providing All Five Hooks? + +Yes, completely. The 10-axis model (Want, Secret/vulnerability, Relationships, Tolerance threshold, Daily routine, Information inventory, Contentment + 3 supporting) can absolutely encode an NPC who: +- Has a Want that's an investigation hook (they want something that implicates them in something) +- Has a Secret that's an assassination latent hook (they're someone significant in hiding) +- Has Relationships that create economic chokepoints (they're the only one with the off-world comm codes) +- Has a Tolerance threshold that creates political drama (they're near the edge; push them and the community fractures) +- Has a Routine that creates dating sim opportunity (they gather with others every evening) + +But having all five hooks in one NPC compresses the drama to a point. It works for the initial playstyle discovery (the world IS this person), but it creates a district that has no further depth once the NPC is understood. + +**Conclusion on OQ-R4-E**: One NPC CAN provide all five hooks. The 10-axis model supports this. Across seeds, this produces maximum variation. Within a seed, this produces minimum emergent behavior. The answer to whether it's enough: **no — the minimum for intra-seed replayability is 3 NPCs (one functional triangle), even for minimal-complexity districts**. Miri's five minimum content types should be distributed across a minimum triangle, not collapsed into a single NPC. + +--- + +## OQ-R4-F: Soft Re-Generation Coherence + +### The XOR Problem + +Gestalt proposes `original_seed XOR event_seed` for large-scale post-event re-generation. The question: does XOR-seeded regeneration produce caused variation or random variation? + +The answer is: **it produces deterministic but incoherent variation**. Here's why this fails. + +XOR combines two bit patterns arithmetically. The result has no semantic relationship to either input. `original_seed XOR event_seed` will always produce the same output (deterministic — good), but that output has zero structural relationship to the original seed (incoherent — bad). The content generated from the XOR seed would be as likely to produce an upscale market quarter as a burned-out ruin, because the XOR seed has lost all information about what the original district was. + +This violates Ozzie's principle ("destruction must be CAUSED") and Miri's cultural response model (aftermath must be an intensification of existing character, not a transformation of it). A Frost-heritage community that has experienced a violence event should look MORE Frost (tighter, colder, doors closed, nobody talks to strangers) — not like a random re-roll that might be more Tide than the original. + +### The Replayability Dimension of Re-Generation + +Before proposing an alternative, I need to address the replayability question directly: **does any form of re-generation produce different-enough results across playthroughs of the same seed?** + +The answer depends on what varies. If the post-event state is deterministic from seed + event type + event location + pre-event state, then two playthroughs of the same seed that both experienced the same event in the same location would produce identical post-event states. That's correct — same seed = same world. The variation across playthroughs comes from whether the event happens and when, not from random variation in the aftermath. + +What the storyteller controls: whether and when to activate a fragility, trigger a trauma event, or cause structural damage. Different playthroughs of the same seed can have different event histories, producing different delta layers on the same base world. That's the replayability engine — not random reseeding of chunks. + +### The Alternative: Typed Modification Instead of Re-Generation + +No event actually requires chunk re-generation. Every event type maps to a `ChunkMutations` overlay: + +**Fire/explosion:** +- Tile overrides: burned floor tiles, collapsed wall tiles, ash-layer objects +- Structural changes: specific walls marked as destroyed +- Object removal: combustible objects replaced with debris objects +- NPC modifications: displacement of NPC home points (their space no longer exists) + +**Building collapse:** +- MultiBlockReservation modified: vertical extent reduced +- Floor zone zone_type changed from active to Ruins for affected z-levels +- Structural changes at block level: entry points sealed + +**Economic catastrophe:** +- NPC contentment/want axes modified for residents +- Object states changed (shops closed, market stalls empty) +- No physical world changes — the buildings are still there, the market stalls are still there, but they're empty and the NPCS know it + +**Political upheaval:** +- Triangle activation changes (storyteller fires suppressed triangles) +- NPC relationship modifications +- Access tier changes (previously Semi-Private zones lock down to Restricted) +- No physical world changes + +In NONE of these cases is chunk re-generation needed. The `ChunkMutations` overlay handles all of them. Tyre's existing architecture is sufficient. + +**The one case that might seem to require re-generation**: district-scale catastrophe (full district destroyed). My answer: this isn't re-generation — it's a new setting type. A district that has been catastrophically destroyed becomes a **Ruins** district. The ruins are generated from the SAME master seed, using the original district generation, with a damage overlay applied at Phase 2 that marks everything as destroyed. The ruins look like THAT district's ruins, not randomly generated rubble — because the tiles underneath are the same tiles, just tagged as destroyed. + +### Formal Position on OQ-R4-F + +**XOR-seeded re-generation is the wrong tool.** It produces deterministic but semantically incoherent results. It cannot satisfy the "caused, not random" requirement. + +**The correct approach:** +1. **Small-scale events** → `ChunkMutations` with event-typed tile overrides and structural changes +2. **Medium-scale events** → `ChunkMutations` with broader structural changes + NPC state modifications +3. **Large-scale catastrophe** → Original ChunkData + comprehensive damage overlay at Phase 2; setting type changes to Ruins; no re-seeding + +**Replayability outcome**: The modification layer varies per playthrough (different events occur at different times, or don't occur at all). The base world never changes (same seed = same underlying district). Two playthroughs of the same seed can have completely different physical worlds in a district if one playthrough triggered the gas explosion and the other didn't. That is the replayability engine. The variance is in EVENT HISTORY, not in random chunk re-generation. + +**What I'd put in the D-record**: "Post-event world modification is handled exclusively through the mutation overlay system (`ChunkMutations` / `WorldStateDelta`). The generator never re-runs for a generated district. Re-seeding via XOR is explicitly rejected as producing semantically incoherent results." + +--- + +## Final Replayability Sign-Off: 12 D-Ready Items + +For each item, I apply the Comparison Test: would two instances of this mechanism (same template, different seeds) produce distinguishable player experiences? And: would the same instance across two playthroughs of the same seed produce meaningfully different play? + +--- + +**D-READY-1: DistrictLayoutMode — Grid and Organic Support** + +**PASS — SIGNED OFF.** + +Comparison Test: Grid district vs. Organic district of the same type → structurally different player experience (spatial reconnaissance is genuinely different; the assassin who uses a mental grid template is wrong in Organic mode). Same seed always produces the same layout mode — correct. The variation is across world regions, not within a seed. + +One replayability note for the D-record: the distribution of Grid vs Organic districts must itself be seeded (different seeds produce different proportions of Grid/Organic across their worlds). If every generated world has Grid at the center and Organic at the margins, that's a predictable pattern a player can exploit. The distribution proportion should vary per seed. + +--- + +**D-READY-2: Guarantee Tier System — Universal / Full-Only / Conditional** + +**PASS — SIGNED OFF.** + +Comparison Test: Two Full-complexity districts (different seeds) → both satisfy Tier 2 guarantees, but the SPATIAL REALIZATION differs per seed (which specific chunk is the Traffic Chokepoint, where the Encounter Corridor runs, what the Elevated Vantage position overlooks). The guarantee doesn't determine configuration, only presence. This is the right design — minimum guaranteed content without constraining configuration to a template. + +One caveat: the guarantee system must not anchor archetypes to fixed positions within the district footprint. If the guarantee states "must have an Elevated Vantage" but the generator always places it northeast of the Social Hub, experienced players will use that pattern. The implementation must verify that archetype spatial positions vary in angular distribution across seeds. This is my earlier hard requirement, still standing. + +--- + +**D-READY-3: TrianglePurpose Enum** + +**PASS — SIGNED OFF (with a note).** + +Comparison Test: Triangle purpose tags don't produce variation — they produce RELEVANCE. Two instances of the same triangle model with different purpose tags activate under different player playstyle conditions. This is not a variation mechanism; it is a targeting mechanism that prevents the storyteller from surfacing wrong-playstyle drama at the wrong moment. + +For the D-record: `TrianglePurpose` is not a replayability feature — it is a multi-playstyle accessibility feature. Its replayability contribution is indirect: by activating the right triangles for the player's current lens, it ensures that drama which exists in the world is surfaced to the player who can engage with it, rather than being invisible noise. + +--- + +**D-READY-4: WallBackside / TileBehindState** + +**PASS — SIGNED OFF (with a condition).** + +Comparison Test: Same district type, different seeds → different wall backside configurations. The proportion of `HiddenRoom` vs. `ServiceVoid` vs. `StructuralFill` must vary per seed, not be fixed by template. If every commissary wall always has a `ServiceVoid` on the other side, exploration is template-matching. The specific backside assignment must be seeded. + +Condition for the D-record: **Backside assignments within a template must have seed-driven variation in their specific distribution.** The template can constrain TYPES (this district type can have HiddenRooms; this template slot is always StructuralFill) but the specific assignment per wall tile should vary. 90% automated tagging (Tyre's number) should mean 90% from seeded probabilistic rules, not 90% from fixed template values. + +--- + +**D-READY-5: Dynamic Modification via Overlay (Not Re-Generation)** + +**PASS — SIGNED OFF.** + +This is the most important replayability mechanism in the architecture. The overlay model makes the same-seed-different-playthroughs scenario possible. The base world is identical across all playthroughs of the same seed. The modification history diverges based on what events the simulation has produced. Two players who started the same seed, made different decisions, and triggered different events have genuinely different physical worlds after those events — while sharing the same generator baseline. + +This is EXACTLY how replayability should work: same world, different history. + +--- + +**D-READY-6: ZonePalette Modifier System** + +**PASS — SIGNED OFF.** + +Comparison Test: Industrial farmland vs. rustic farmland → visually distinguishable. Frost-heritage industrial farmland vs. Tide-heritage industrial farmland → also distinguishable. The modifier system produces substantial combinatorial variety from a small base set. + +Replayability note: palette combinations are static per district per seed. The same seed always produces the same palette. The variation is across seeds and across district types — not within a playthrough or across playthroughs of the same seed. This is correct. Visual identity of a place should be stable. + +One note I want in the D-record: palette modifiers should influence NPC appearance as well as environment appearance. A Frost-heritage district should have NPCs whose clothing/gear palette is consistent with the Frost material grammar. This extends the "caused not random" principle to NPC appearance — people dress like they're from here. + +--- + +**D-READY-7: Horizon View Corridor as Coastal Guarantee** + +**PASS — SIGNED OFF.** + +Comparison Test: Two coastal districts (different seeds) → both have horizon view corridors. The corridors are in different locations, overlook different portions of water, have different surrounding context. The MOMENT is guaranteed; the specific experience is seeded. + +From a replayability standpoint: the horizon view is one of Ozzie's primary Wow Moments. Its value is partly in its unexpectedness — the player turns a corner and sees the ocean. If the horizon view corridor is always in the same relative position to the district entry point, experienced players expect it and the Wow diminishes. The reservation should constrain the corridor's existence and minimum width, but not its position. Let the generator place it wherever the spatial configuration produces it, as long as it exists. + +--- + +**D-READY-8: Assassin Lens Spatial Guarantees (A-1 through A-4)** + +**PASS — SIGNED OFF (with the angular variation requirement).** + +Comparison Test: Two Full-complexity districts (different seeds) → both satisfy A-1 through A-4. The Elevated Vantage is in a different position. The Egress Multiplicity routes run different directions. The Temporal Opacity Window is at a different day-phase. + +My standing hard requirement: archetype placement must vary in **angular position** across seeds, not just in distance from center. This requirement applies directly to the Elevated Vantage (A-1) and the relationship between it and the Traffic Chokepoint. If the Elevated Vantage is always north of the Traffic Chokepoint, every assassination approach is the same elevation/angle relationship regardless of seed. The guarantee system must verify that the angular distribution of archetype positions across multiple seeds is not clustered. + +This is a verification requirement, not just a generation requirement. The guarantee audit should fail if archetypes are generated in positions that form a predictable template. + +--- + +**D-READY-9: Heritage Grammar Overlay for Non-Urban Palettes** + +**PASS — SIGNED OFF.** + +Comparison Test: Frost-heritage farmland vs. Tide-heritage farmland → substantially different organizational grammar (Frost: individual plots, fenced separations, minimal communal space; Tide: open gradients, communal gathering areas, fluid spatial boundaries). This is not just cosmetic — the spatial grammar affects which playstyle affordances are naturally present (Tide produces more obvious Social Hub expressions; Frost produces more physical_distance informal zone expressions). + +For the D-record: Heritage grammar is a generative input, not a decorative overlay. It shapes which archetypes are easy to satisfy and which are difficult. A Frost-heritage district has natural physical_distance informal zones but needs deliberate effort to produce an Encounter Corridor. This constraint shapes the district's playstyle affinity — which is the right level of influence. + +--- + +**D-READY-10: Non-Urban Informal Zone Typology** + +**PASS — SIGNED OFF.** + +Comparison Test: Frost-heritage wilderness district → `physical_distance` informal zone type. Tide-heritage maritime district → `social_permission` informal zone type. The type is determined by heritage root — consistent and predictable. The LOCATION within the terrain is seeded. + +One replayability note: the three informal zone types (social_permission / physical_distance / utilitarian_cover) create meaningfully different gameplay even when the player knows which type they're in. `Social_permission` means cover is about convention, not geography — you can be seen, you just can't be judged. `Physical_distance` means cover requires travel — you have to physically remove yourself. `Utilitarian_cover` means you need a functional excuse for your presence. Each type demands different strategies. The variation is not in "what is the informal zone" but in "how does one USE it." That's deep replayability from a simple typology. + +--- + +**D-READY-11: Vertical Scale Architecture** + +**PASS — with a replayability condition for the D-record.** + +Comparison Test: Two Full-complexity skyscrapers (same heritage, different seeds) → different floor zone assignments? If z-band assignments are purely deterministic by building function (corporate building always has labor on 1-5, operations on 6-20, executive on 21-30), then experienced players can predict what's on floor 30 without visiting. That's the Second Station Syndrome applied vertically. + +**Condition for the D-record**: z-band boundaries must have seed-variation within cultural constraints. The cultural model constrains the ORDERING (labor below operations below executive), but not the exact floor numbers. A corporate building in one seed has executive starting on floor 22; in another seed, floor 35. The player who knows "executive floors are in the upper third" is working with useful knowledge, but can't skip exploration — they still need to find the actual executive zone. + +Secondary replayability property of vertical scale: **vertical access routes are playthrough-history dependent**. A player who befriended the building's head of facilities on an earlier encounter can now access the service elevator directly. A player who damaged the main elevator bank in a previous event now has to find an alternative route. The building's access topology is fixed by the seed; which routes are available to the player at any moment is determined by their relationship and event history. Same building, different access experience per playthrough. + +--- + +**D-READY-12: Trauma Events as EraModification Subtypes** + +**PASS — SIGNED OFF.** + +Comparison Test: Same district before and after a PhysicalDestruction trauma event → different NPC pattern weight distribution (ANCHOR/WITNESS/REMNANT increase; normal distribution suppressed). The change is predictable from the heritage root — Frost communities respond differently to trauma than Tide communities. Two Frost-heritage communities that experience the same trauma type respond similarly (same cultural grammar). Two communities with different heritage roots diverge. + +This is the correct behavior. The trauma response is not random — it is culturally CAUSED. Ozzie's principle is satisfied. + +One replayability note: **the decay rate** (how quickly the cultural aftermath fades toward baseline) should be seeded at generation time with variation. A Frost-heritage community might always be slow to recover (heritage root determines the baseline rate), but the SPECIFIC rate for this community is seeded (some Frost communities are 20% faster to recover than the mean; others are 20% slower). This prevents trauma response from being perfectly predictable from heritage root alone — it adds the "this specific community" dimension that makes individual settlements feel distinct. + +--- + +## Summary: Round 4 Positions + +**OQ-R4-A resolved:** MobileChunk accepted. The vessel is a stage (persistent interior) + a cast (per-voyage manifest). Manifest seeded from `derive_seed(master, "vessel_manifest", vessel_id, voyage_index)`. At least 50% of variable passenger slots must turn over between adjacent voyages. Crew persistent across voyages. In-transit events voyage-seeded (not vessel-seeded). Arrival time is storyteller-modifiable. Vessel accumulates ChunkMutations across its lifespan. Requirements R-V-1 through R-V-6 stated for the D-record. + +**OQ-R4-E resolved:** One NPC can provide all five playstyle hooks simultaneously. 10-axis model supports this fully. BUT: one NPC produces zero intra-seed emergent behavior (no relationships = no triangles = no emergence). The minimum for meaningful intra-seed replayability is 3 NPCs (one functional triangle), even for minimal-complexity insignificant districts. Miri's five minimum content types should be distributed across a minimum triangle. + +**OQ-R4-F resolved:** XOR-seeded re-generation is explicitly rejected — it produces deterministic but semantically incoherent results. The correct approach: typed `ChunkMutations` overlays for all event scales. The generator never re-runs for a generated district. District-scale catastrophe produces a Ruins overlay on the original ChunkData, not a re-seeded replacement. Replayability comes from event history divergence across playthroughs, not from random chunk re-generation. + +**12 D-ready items:** All signed off. Three replayability conditions to include in D-records: +1. (D-READY-1) Grid/Organic distribution proportion must vary per seed — not fixed to a predictable geographic pattern. +2. (D-READY-8 / D-READY-2) Archetype placement must vary in angular position per seed — not just distance from center. This is verifiable and testable. The guarantee audit should fail if archetypes cluster in predictable angular positions across a test run of N seeds. +3. (D-READY-11) Z-band floor boundaries must have seed-variation within cultural ordering constraints — experienced players should know "executive is in the upper zone" without knowing which exact floor that begins on. diff --git a/docs/workshops/generator-architecture/nigel-round5.md b/docs/workshops/generator-architecture/nigel-round5.md new file mode 100644 index 000000000..cc572bb41 --- /dev/null +++ b/docs/workshops/generator-architecture/nigel-round5.md @@ -0,0 +1,110 @@ +# Generator Architecture Workshop — Round 5: Nigel (Final Review) + +**Date:** 2026-02-27 +**Role:** Replayability Advocate +**Task:** Sign-off review of workshop-outcomes.md. Corrections only. + +--- + +## Sign-Off Status + +**SIGNED OFF** with two corrections required and two notes. + +--- + +## Verification Checklist + +**1. MobileChunk replayability guarantees — PASS WITH NOTE** + +D-READY-13 correctly captures: vessels as persistent world entities, Docked state with dock_position, boarding via gangway, interior cache persisting crew state across voyages, departure schedule as required generator output (error state if missing). The stage+cast framing is correct. + +Note: R-V-1 through R-V-6 are cited by reference only ("see round-4-notes.md §5"). This is acceptable if round-4-notes.md is preserved. For D-record durability, R-V-1 (≥50% variable manifest slots must differ between adjacent voyages) and R-V-3 (in-transit events are voyage-seeded, not vessel-seeded) are the two most critical for implementation correctness and should be verified to appear in the referenced section before filing. + +**2. Minimum 3 NPCs for intra-seed replayability — PASS** + +Explicitly stated: "Minimum NPC count for intra-seed replayability: 3 (one functional triangle). One NPC = maximum seed-to-seed variation, zero intra-seed emergence. Three NPCs = triangles, shifting alliances, cascade effects. Even Minimal-complexity insignificant districts need 3 NPCs." Correct and complete. + +**3. RegenerationStrategy enum — PASS** + +`LocalOverlay / SoftReseed / FullReseed` correctly maps in-playthrough / scenario-boundary / era-level. XOR prohibition for in-playthrough events is stated as a hard constraint and confirmed as lead decision L-7. The allowance of SoftReseed for scenario-boundary events is a valid nuance — the player was not present, so causal legibility is not required. Correct. + +**4. DramaDensity as runtime state — PASS** + +L-5 is explicit. The three-layer model correctly places `drama_density` under `SIMULATION STATE (runtime storyteller — NOT generator output)`. Not on DistrictSkeleton. Confirmed. + +**5. Three-parameter model (WorldTier + ComplexityTier + DramaDensity) — CORRECTIONS REQUIRED** + +Two separate issues found: + +--- + +## Correction 1 — WorldTier Ceiling Conflict (Local) + +The constraint table states: +> "WorldTier → ComplexityTier ceiling: Core/Regional → Full max; **Local → Moderate max**; Transit → Minimal; Dormant → Empty." + +The very next sentence states: +> "A narratively critical backwater can be `WorldTier::Local + ComplexityTier::Full`." + +These are directly contradictory. Local cannot simultaneously have a Moderate ceiling and an explicit Full-complexity example. + +**Root cause:** The WorldTier enum description for `Local` includes "limited budget," conflating network significance (Local = low external connectivity) with simulation budget (which should be fully determined by ComplexityTier alone). WorldTier is about network position. ComplexityTier is about generator output depth. They are independent — this was the central Round 3 insight confirmed by all three participants. + +The `Backwater + Full complexity` case is fundamental to the game's design ("Insignificance is a lens, not a verdict"). A dense, isolated community of 150 people who've lived together for 40 years can be as socially rich as any hub — it's just not connected to the wider network. + +**Required fix — corrected ceiling table:** + +| WorldTier | ComplexityTier ceiling | +|-----------|------------------------| +| Core | Full | +| Regional | Full | +| **Local** | **Full** | +| Transit | Minimal | +| Dormant | Empty | + +Also remove "limited budget" from the `Local` enum comment. Budget is ComplexityTier's responsibility, not WorldTier's. + +--- + +## Correction 2 — Missing ComplexityTier → DramaDensity Ceiling + +The document states the first half of the constraint chain: +> WorldTier constrains ComplexityTier ceiling ✓ + +But does NOT state the second half: +> ComplexityTier constrains DramaDensity ceiling ✗ (missing) + +This constraint is load-bearing. A `ComplexityTier::Empty` district has no NPCs, no social sites, no triangles. The storyteller cannot meaningfully activate drama in a district with no social fabric. Allowing the storyteller to set `DramaDensity::Flashpoint` on an Empty district is a bug category, not a design option. + +**Required addition** — after the existing WorldTier → ComplexityTier ceiling table, add: + +> ComplexityTier → DramaDensity ceiling: Full → any intensity; Moderate → Active max; Minimal → Quiescent max; Empty → Zero only (no storyteller activation possible). + +(Using the 3-level enum values currently in the struct: Quiescent / Active / Intense.) + +This does not change any other part of the document. It is a missing half of the stated constraint chain. + +--- + +## Notes (Not Corrections, Not Blocking) + +**Note 1 — DramaDensity enum naming (Q-NNN-d)** + +Q-NNN-d flags Round 4 struct as Quiescent/Active/Intense (3 levels) vs. Round 3's Zero/Low/Medium/High/Flashpoint (5 levels). My preference is 5 levels for finer storyteller control. Specifically: the distinction between `High` ("this is the current narrative focus") and `Flashpoint` ("crisis actively destabilizing the world") is a categorically different gameplay state that should not collapse into a single `Intense` value. If 3 levels is chosen for implementation simplicity, I ask that Flashpoint be preserved as the distinct peak value — it is the storyteller's maximum-pressure instrument and deserves its own state. + +This is a Q-record and sprint work. Flagging my position, not blocking. + +**Note 2 — Q-NNN-b Resolution** + +Q-NNN-b is listed as unresolved: "Departure schedule model — departure windows as generator output for docked vessels." But D-READY-13 already states: "`scheduled_departure: Option<SimTick>` field in Docked state satisfies this; the generator must populate it. Vessels without departure schedules are an error state." This appears resolved within the document. Recommend closing Q-NNN-b before sprint planning. + +--- + +## Overall Verdict + +The document correctly captures the workshop architecture. Two corrections must be applied before D-records are filed: + +1. Local ceiling must be Full (not Moderate) — the backwater case is core design +2. ComplexityTier → DramaDensity ceiling must be stated — it is half of the constraint chain + +Everything else accurately reflects my Round 3-4 positions. The replayability architecture this document specifies will deliver: two players comparing the same world mod will have wildly different stories to tell. diff --git a/docs/workshops/generator-architecture/ozzie-round1.md b/docs/workshops/generator-architecture/ozzie-round1.md new file mode 100644 index 000000000..308c699d9 --- /dev/null +++ b/docs/workshops/generator-architecture/ozzie-round1.md @@ -0,0 +1,126 @@ +# Generator Architecture Workshop — Round 1: Ozzie + +**Role:** Player Experience / Fan Perspective +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## Disclaimer: I Am Not An Architect. I Am A Player. + +I don't care about pipelines. I don't care about "zoning passes" and "district skeletons." I care about one thing: when I walk into a new station for the first time, does it feel REAL? Does it feel like somewhere people actually live? And when I come back to a different station six hours later, is it still exciting — or is it the same grey corridor wearing a different hat? + +That's my job here. Make the case for the player. Shout when something sounds wrong. Lose my mind when something sounds right. + +--- + +## What I Need From 300 Worlds + +Three things. Non-negotiable. + +**1. A reason to explore.** + +Every station needs a "wait, what's that?" moment within the first two minutes of arrival. A weird shop jammed into an alcove. A maintenance ladder going somewhere it probably shouldn't. Two NPCs having a conversation that stops when you get close. Something. If I land on a new station and my first impulse is "okay, where's the quest marker," you've failed. The space itself needs to be a question. + +**2. A place I'm not supposed to be.** + +I need to find somewhere that feels like I wasn't meant to find it. A back room. A rooftop access. A corridor that's not on the map. This can't be everywhere — if it's everywhere, it's nowhere — but it has to exist, and I have to find it myself, not be guided to it. The generator needs to produce these spaces reliably. Not as marked secrets. Just as... the consequences of how a real place is built. + +**3. Density contrast.** + +Crowded market → narrow service corridor → sudden open atrium. That rhythm. That's what tells me this is a place that evolved, not a place that was assembled from a template. If every district has consistent density, consistent ceiling height, consistent noise level, it feels fake. The generator needs to produce contrast even when it's pulling from templates. + +--- + +## The Generation Sins + +Here's what would make me quit. + +**Sin #1: The Second Station Syndrome.** + +I arrive at station two and I immediately recognize the skeleton. Oh, it's the market district again. The bar is in the northwest quadrant, the transit hub is in the southeast, there's a service corridor along the east edge. Even if the art is different, if I recognize the SHAPE, the spell is broken. I stop exploring because I already know what's there. This is the death of the game. + +**Sin #2: Decorative variation, structural sameness.** + +If the generator produces districts that are geometrically different but functionally identical — same chokepoints, same social patterns, same access — then we've just reskinned the same space. I don't need a different coat of paint. I need a different reason to pay attention. + +**Sin #3: Dead space.** + +Rooms that exist but contain nothing — no NPCs, no loot, no environmental story, no reason to be there. Procedural generation loves to produce dead space. It's the easiest thing in the world to generate a corridor with nothing in it. I should never arrive somewhere and feel like I'm in a loading zone for somewhere else. Every space needs to pay rent. + +**Sin #4: Predictable secrets.** + +If I learn the grammar of how secrets are hidden — always in the northeast corner, always behind a stack of crates, always through a vent — then secrets become routine. "Oh, there's the secret." That's the opposite of discovery. The generator's variation needs to extend to WHERE things hide, not just what they look like. + +**Sin #5: "Generated" readable from the outside.** + +If I can look at a district and think "yes, this is clearly procedural" — I'm done. The aesthetic tells me this is content filler. Real places have weirdness in them. An awkward staircase that goes nowhere useful because the building was extended. A niche that's a completely different architectural style because it was added later. Irregularities that suggest history. The generator needs to produce irregularities. Not random noise — meaningful-feeling irregularities. + +--- + +## What Makes the 50th Station Exciting + +This is the real question. + +For the 50th station to be exciting, the generator can't just be varying surface features. It has to be varying WHAT MATTERS. + +What matters to me as a player: +- **Who has power here** — and how it shows in the space. A station under Intersolar Commonwealth control looks and moves differently than one under de Terre influence. That's not just art. That's which doors are locked, which NPCs are nervous, where the cameras point. +- **What happened here** — the environmental story. A district that used to be prosperous and isn't anymore. A market that's clearly improvised after something was destroyed. Spaces tell history. The generator needs inputs that make history legible. +- **What's the social architecture** — where do the hierarchies show up physically? Where do the powerful people eat lunch? Where do the workers hide from supervisors? This makes stations feel like societies, not buildings. + +If the generator is just varying layout geometry and art themes, the 50th station is just the 50th palette swap. If it's varying the SOCIAL AND POLITICAL TEXTURE — who's in charge, what they want hidden, what grudges are still warm — then I will happily explore the 100th. + +--- + +## Hand-Crafted vs Obviously Procedural + +Let me be honest: I don't care which one it is, as long as it READS as hand-crafted. + +No Man's Sky fooled me for about 30 minutes. Dwarf Fortress fools me forever. The difference isn't technical — it's whether the output shows evidence of considered choices. A DF fortress feels hand-crafted because it's the product of systems that have opinions. The weird L-shaped room exists because miners hit an aquifer. That's not random — that's causal. + +The sub-chunk quarter system sounds promising to me. L-shapes, merged footprints, irregular structures within a grid — those are the right instincts. The question is whether the results will feel CAUSED (this building is shaped this way because something made it this way) or RANDOM (this building is shaped this way because the RNG said so). Caused feels hand-crafted. Random feels generated. + +What I need: the irregularities to feel like they have reasons, even if I can't articulate them. If I look at an L-shaped building and unconsciously think "yeah, that makes sense here," we've won. If I look at it and think "huh, weird," we've failed. + +--- + +## What Spatial Surprises Matter Most + +In order: + +**1. Vertical surprise.** Going up when I didn't expect to. A building that reveals a second floor. A shaft. A balcony looking down on a space I thought was at ground level. This reorients my mental map in a satisfying way. + +**2. Density inversion.** Finding something quiet in the middle of a loud area. Or something bustling inside what looked like a dead zone. The contrast is the surprise. + +**3. Shortcuts that feel earned.** Not obvious shortcuts — those are just map design. A way through that I discovered, that I now own. A loose panel, an underused service route, an NPC who lets me through if I've done something for them. The generator needs to produce the SPATIAL POSSIBILITY of shortcuts; the systems layer turns them into earned ones. + +**4. Hidden audience.** Spaces where I can watch without being seen. Or where I realise I'm being watched. This matters enormously for the game's investigation core. A generated district that has no natural sightline asymmetry — nowhere to stand and observe — is useless for the game's fantasy. + +**5. A place that's too small to be safe.** A tiny space with one exit. A maintenance crawl that opens into someone's private room. The tension of confined geometry. The generator should occasionally produce spaces where the SHAPE creates vulnerability. + +--- + +## My Gut Reaction to the Sub-Chunk Quarter System + +This is the most interesting idea in the brief and the one I'm most nervous about. + +The nervous part: "quarters that can merge, split, leave gaps, or host shacks/gardens" sounds like it's describing a system that produces visual variation within a regular grid. That's fine. But does it produce SOCIAL variation? Does the choice of what fills a quarter have downstream consequences — does a garden quarter mean something different about who lives nearby versus a shack quarter? If the quarters are just aesthetic choices, I'll see through them on the second station. + +The exciting part: the irregularity principle. L-shapes, gaps, merged footprints — these are exactly the right inputs for making spaces feel like they grew rather than were placed. If I walk into a block and the building footprints are subtly wrong, are bumped against each other, leave a weird sliver of space between them — I will explore that sliver. I will want to know what's in it. + +The worry: the grid will show. Even with quarter variation, if the blocks are always the same size and the streets are always aligned, I'll feel the skeleton underneath. What breaks the grid-ness isn't varying what's inside the grid. It's varying the grid itself — odd street angles, blocks that don't quite fit, infrastructure that predates the current layout. Can the generator produce HISTORICAL PALIMPSEST? Can it make stations that feel like they were built in layers by different people with different plans? + +That's what I want. Not variety. History. + +--- + +## Summary: What I'm Rooting For + +A generator that asks not "how do we produce 300 different-looking stations" but "how do we produce 300 stations with different STORIES encoded in their geometry." + +The technical architecture doesn't matter to me. What matters is whether the output makes me want to be there. Whether walking into a district gives me questions I want answered. Whether the space itself is a puzzle — not a quest-marker puzzle, a human puzzle. Why is this corridor blocked? Why do people avoid that corner? Who decided this building needed to be this shape? + +Give me questions encoded in space. That's all I ask. + +Make me care about the 50th station as much as the first. THAT'S the design challenge. THAT'S what I'm here to fight for. diff --git a/docs/workshops/generator-architecture/ozzie-round2.md b/docs/workshops/generator-architecture/ozzie-round2.md new file mode 100644 index 000000000..3a173b090 --- /dev/null +++ b/docs/workshops/generator-architecture/ozzie-round2.md @@ -0,0 +1,244 @@ +# Generator Architecture Workshop — Round 2: Ozzie + +**Role:** Player Experience / Fan Perspective +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## I've Read Everything. Here's My Gut. + +First: I'm floored. These five people are THINKING. The cultural ingredients system is exactly the kind of thing that could save this game from the procedural-sameness death spiral. The visual grammar constraints make me feel like the spaces will actually mean something. Nigel's comparison test — two players comparing notes and finding incompatible experiences — is EXACTLY the right design target. + +But I have things to say. Let's go. + +--- + +## On Tyre's Technical Architecture + +**Does it address my grid concern?** Partially. But not enough. + +Tyre told me the quarter system is "generation-side only" — it disappears after the fill pass. The server sees tiles. The player sees space. And the L-shape, the FullMerge, the MergeH options — these sound right. These sound like the difference between a building that was planned and a building that grew. + +But here's what Tyre didn't answer: does the BLOCK grid vary? The 128×128 sim tile block, the 4×4 arrangement within a district — those stay fixed. The streets between blocks always align to the same grid. Every block has the same 64m² footprint. Even with all the L-shapes and merged chunks in the world, I will eventually feel that 64m grid underneath me. + +What I need Tyre to address in the next round: can the STREET GRID rotate? Can blocks be non-rectilinear at the geography/infrastructure stage? If every district is a perfect 4×4 grid of identical-footprint blocks with perpendicular streets, I will feel the skeleton on the 5th station. + +The edge contracts system is brilliant — chunks promising each other what their boundaries look like, so the generator can fill them coherently. That's the right instinct for how borders work. It's also, interestingly, the system that could ALLOW the grid to rotate, if the edge contracts can handle non-90-degree intersections. + +The borderless generation implication is the most exciting thing in Tyre's output. "District generation must be local, not global" — if that means districts can grow organically from their neighbors rather than being stamped on a grid, I want that. BADLY. + +**What I need:** Tell me the grid can breathe. Tell me two adjacent districts can have different orientations. Tell me a street can curve because the geography required it. + +--- + +## On Miri's Cultural Ingredients System + +**Does it solve Second Station Syndrome?** YES. This is the answer. + +I asked in Round 1: does the 50th station have different SOCIAL AND POLITICAL TEXTURE? Miri said yes. And she showed her work. + +The Frost/Salt/Iron + tight-margin + prohibition-economy combination for Sova — that's not decorative. That's mechanically load-bearing. A Frost-dominant culture means silence is NORMAL. On a Tide/Vine culture station, silence is suspicious. The SAME NPC BEHAVIOR means opposite things. That's not the 50th palette swap. That's a completely different game. + +The absence parameters are the sleeper hit of Miri's system. A station with no philosophical alignment — pure pragmatism, no ideology — is mechanically different from one with labor-solidarity as a framework. The grey economy on Sova is rational economic behavior. On a solidarity-aligned station, the grey economy is politically organized. The detective confronts completely different moral architecture. + +My concern: the six categories are rich for investigation gameplay. But do they produce variation for OTHER playstyles? I'll come back to this. + +One thing I love unreservedly: "No heritage consciousness → functional naming, no substrate words, no food traditions." A transit hub with high turnover and no heritage consciousness is a place that doesn't have a self-concept. That's lonely in a way that makes me want to understand it. That's STORY. + +--- + +## On Gestalt's Seven Spatial Guarantees + +**Too investigation-focused? Yes. And here's why that matters now.** + +The lead just told us this isn't a detective game. It's about asymmetric human awareness. That means the tycoon and the romantic lead and the political operator ALL need spatial guarantees that serve their gameplay. + +Gestalt's seven guarantees are: +1. Surveillance chokepoint +2. Meridian dead zone +3. Social hub +4. Quiet zone / staging ground +5. Vertical access / spatial discovery +6. Triangle staging ground +7. Something mundane that's secretly a crime scene + +Numbers 1, 2, 4, 6, and 7 are detective guarantees. Number 3 and 5 are multi-playstyle. + +This isn't a criticism of Gestalt — Round 1 was written before the broadened lens. But for Round 2, the guarantees need to expand. + +**What the tycoon player needs from spatial guarantees:** +- An economic choke node: somewhere goods or money must pass through, that can be controlled or leveraged +- Competing commercial zones: there's a winner and a loser, spatially expressed +- Infrastructure vulnerability: a supply chain that can be interrupted, rerouted, exploited +- A space where deals happen informally: because the formal spaces are regulated + +**What the dating sim player needs:** +- A third place: somewhere that's neither work nor home, where social barriers drop +- Ritual gathering points: the shift-change bar, the market morning, the rooftop at dusk +- Privacy gradient: somewhere that can transition from public to intimate, spatially +- A place to be seen: because romance happens in public before it happens in private + +The surveillance chokepoint and the romantic encounter spot can be the SAME ROOM, seen through different lenses. That's the design target. But Gestalt's guarantees currently don't name the spaces in terms that serve the romantic's playthrough. + +**My ask to Gestalt for Round 2:** Frame the guarantees in terms that serve EVERY playstyle. "Social hub" is doing real work here already — a bar is an investigation staging ground AND a dating venue AND where the tycoon hears gossip about competitors. But the guarantees that are purely investigation-flavored need to be reframed as multi-use spatial types. + +--- + +## On Araminta's Visual Grammar + +**Would I want to explore these spaces?** HELL YES. With one caveat. + +Araminta gave me the most technically precise output of the round, and somehow it's the most emotionally reassuring. The zone palettes produce GEOGRAPHY OF FEELING. Cold institutional white → cargo grey-navy → dark transit → amber social warmth. Walking THROUGH those temperature zones is a spatial experience. That gradient is a story the player's body tells before their brain catches up. + +The LOS anchor rule — every quarter must have at least one structural break — is the anti-dead-space rule I was screaming for in Round 1. Every quarter pays rent. I could cry. + +The "settled principle" — space feels inhabited when it has accumulated objects, not large open areas — is the best single sentence in all of Round 1. That's not a visual rule. That's a philosophy of place. PLACES ARE WHAT PEOPLE DO IN THEM. + +My caveat: Araminta's visual grammar is extremely station-centric. The zone palettes (gate cluster cold white, terminal institutional, maintenance dark, bar amber) are all interior urban spaces. What does this grammar do when we're on a planet? When we're in farmland? When we're at a beach resort? + +I need to know that the visual grammar can produce the FEELING OF OUTSIDE. Not just different hex codes — actual spatial openness, natural light quality, the disorientation of no walls. Station-born players arriving at their first planet-side destination should feel DIFFERENT. The visual grammar needs to be able to generate that. + +And actually — the CONTRAST between station interior and planetary exterior is a Wow Moment. Walking through a gate aperture and suddenly the hex codes change and the light comes from above and there's horizon? That's a moment I'd tell someone about. Does the visual grammar plan for that moment? + +--- + +## On Nigel's Replayability System + +**Nigel gets it. Nigel REALLY gets it.** + +Structural randomness over surface randomness. The entanglement assignment is more important than the quarter merge. The social graph varies, not just the NPC portraits. Knowledge rot across playthroughs. The comparison test. ALL OF THIS. + +"The generator doesn't produce 300 worlds. It produces 300 × (character options) × (cultural combinations) × (seed entropy) distinct game experiences." I want to put this on a wall. + +The historical event seed is my favorite new idea: "A corporate merger 10 years ago → two different architectural styles visible, NPCs with residual loyalty conflicts." That's the historical palimpsest I asked for. That's CAUSED irregularity. A building is L-shaped because when the merger happened, they couldn't tear down the original east wing without disrupting operations, so they just built around it. + +My one concern about Nigel: the flavor structure categories are heavily investigation-biased too. Informal economy indicators, economic stress indicators, faction presence indicators — these are all about social power and grey-market activity. They're the right categories for the investigator. They're less obviously useful for the person who's just trying to understand this place emotionally, or economically, or romantically. + +A "settlement indicator" — container gardens, improvised seating clusters, personal shrines — that's actually multi-use. Those aren't just about crime. Those are about people making a home in an inhospitable place. That's the most human thing in Nigel's list, and I think it deserves to grow. + +--- + +## NEW TERRITORY: Beyond Investigation + +This is what the lead directive is actually asking. Not "does the investigation work?" but "does the WORLD work for every way a human being might engage with it?" + +### What Does a Tycoon Player Need? + +The tycoon is playing an economic game inside the social sim. They're not asking "who's guilty." They're asking "where is value being created and how do I redirect some of it toward me?" + +For the tycoon, the generation sins are: + +**Sin #1: An undifferentiated economy.** If every district has the same mix of economic activity, there's no leverage. The tycoon needs to see the gaps: what does this station import that it could produce? Where is the markup? What infrastructure constraint creates a monopoly opportunity? The generator needs to produce ECONOMIC GEOGRAPHY — places where resources flow through chokepoints, where production capacity exists but distribution doesn't, where there's a market for something nobody's selling. + +**Sin #2: A political landscape with no edges.** The tycoon wants to know who you HAVE to deal with to do business here. Commission-heavy district means permits and bribes in the right direction. Syndic-heavy means the labor rates are set, take it or leave it. Weakly controlled means opportunity but also no enforceable contracts. If the generator produces a political landscape that's uniform — everyone's equally formal or informal — there's no arbitrage. No edges. + +**Sin #3: Infrastructure that's already optimal.** The tycoon wants broken things. Inefficient routes. Supply chains that add two steps because nobody thought to build a connector. A generator that produces perfectly optimized infrastructure leaves no economic opportunity. + +What makes a tycoon EXCITED about a new station: spotting an inefficiency in the third minute of exploring. "Wait — they're bringing freight in through the passenger terminal? That's expensive. If I could negotiate with the freight operator AND the Commission checkpoint supervisor..." That's gameplay. + +The generator needs to produce the SPATIAL CONDITIONS for that moment. Not the moment itself — just the infrastructure that makes it imaginable. + +### What Does a Dating Sim Player Need? + +The dating sim player is building relationships. They want to understand people, to matter to them, to be known by them. The game's central mechanic — asymmetric information, trust tiers, invisible dialogue — actually SERVES this playstyle beautifully. The investigator uncovers secrets. The romantic does too. They just do it with different intent. + +For the romantic, the generation sins are: + +**Sin #1: No private space that feels earned.** Romance needs gradient — from public encounter to private access. If every space is either completely public or locked-restricted, there's nowhere to actually be alone with someone. The generator needs to produce spaces that are TECHNICALLY public but FEEL intimate: the quiet corner of the bar, the maintenance corridor that nobody uses in the afternoon, the roof access that's not actually supposed to be accessible. + +**Sin #2: No ritual time.** Romance happens in repeated encounters. The shift-end bar crowd, the morning market regulars, the rooftop people who always seem to be there at dusk. These are SCHEDULED SOCIAL RITUALS. The generator needs to produce the spatial conditions for recurring social gathering — places where you can FIND someone again, where showing up regularly means something. + +**Sin #3: No story to discover.** The most romantic thing in this game might be learning the history of someone who's been here longer than you. The labor dispute that left a mark. The family that arrived as refugees and built something. The maintenance corridor that's named after someone nobody can quite remember. These are the stories that make you feel like you're in a PLACE, not a simulation. The generator's historical palimpsest system is secretly the most romantic system in the whole game. + +**Sin #4: No contrast between warmth and cold.** Romance is partly about finding warmth in an inhospitable world. A station that's uniformly comfortable has no stakes. The generator needs to produce spaces that are HARSH — cold maintenance levels, loud freight areas, institutional indifference — so the warm social spaces feel like sanctuary. + +What makes a romantic player EXCITED about a new station: walking into the bar after twenty minutes of industrial corridors and going "oh. This is where the people are." + +### What Makes a Backwater World Worth Visiting? + +The lead directive says insignificant backwater worlds are valid. Not everything has to be dramatic. Good. I agree, and I want to push on WHY a backwater is interesting. + +The generation sin for backwaters: **making them feel like unfinished stations.** A backwater that has all the same spatial types as a major hub, just smaller and sparser, is just a bad version of a hub. It's not interesting to visit. It's a station that failed. + +A backwater is interesting when it's ENTIRELY itself. A place that has its own logic, its own completeness, even if that completeness is small. A farming settlement that's been here for 80 years and has exactly what it needs and nothing more. A research outpost where everyone knows everyone and the grey economy is someone swapping lab samples for home-cooked food. An orbital installation where the whole social world is twelve people and the investigation is necessarily intimate because there's nowhere to hide. + +The generation win for backwaters: **density of human detail in a small space.** Because there are only 200 people, every one of them is legible. The cultural drift is highly visible. The political tensions are personal, not institutional. The history is SHORT but SPECIFIC — not "era stratification" but "that happened when Kira was still station manager, which was before the third growing season." + +For the generator, backwaters need: +- Small social site count (maximum 3-4) +- But HIGH social entanglement — everyone is connected to everyone +- A SINGLE dominant economic function that shapes everything (this is a farming world; the whole rhythm is agricultural) +- Cultural ingredients that are strongly expressed because there's been no dilution +- History that's recent enough to be personally remembered + +The most interesting thing you can do in a backwater: become the ONLY outsider. Everyone else has been here for years. Your arrival is an event. + +### What Makes Farmland or Ocean Interesting to Explore? + +This is the one that worries me most, because the architecture as currently described is ENTIRELY built for interior urban spaces. + +**Farmland:** + +The generation sin for farmland: making it an empty field between interesting places. If farmland is just "low density of buildings, lots of open space, nothing happens here," players will skip it. They'll run through it to get to the next settlement. + +Farmland is interesting when: +- The LAND ITSELF is a character. What's growing? What does that tell you about who decided to grow it here and why? +- There are SPATIAL SECRETS specific to farmland: irrigation systems that double as smuggling routes; storage facilities that are genuinely isolated; seasonal gathering points that only exist for two weeks a year +- The scale contrast hits differently. Coming from a cramped station corridor into a field where you can see for 500 meters should feel PHYSICAL. The player's sense of their own observability changes completely +- The people who work farmland are shaped by it. The schedule is agricultural. The culture is seasonal. The grey economy is what you do in the off-season when the money isn't coming in + +The generator needs to produce AGRICULTURAL SPATIAL LOGIC: where the buildings cluster (near water, near roads, near each other for social life), why certain areas are left alone (flood risk, poor soil, someone's dispute), what the sight lines mean (a farmworker who can see for kilometers has a completely different relationship to surveillance than a dockworker in a corridor). + +**Ocean / Beaches / Water:** + +This is the most interesting challenge because water is the one geographic feature that genuinely changes the access topology grammar. You can't walk through it. You have to go around it, over it, or under it. That's a chokepoint that's NATURAL rather than institutional. + +The ocean changes the game for EVERY playstyle: +- The detective: maritime commerce is a different evidence type. Manifests are about ship cargo, not freight containers. Access topology includes the harbor, the dock authority, the tide schedule +- The tycoon: maritime routes are a natural monopoly if you control the harbor infrastructure. The economic geography of a coastal settlement is DRAMATICALLY different from an inland one +- The romantic: beaches and harbors are inherently liminal spaces — transitions between worlds. People are different near water. Social rules are looser. The romantic tradition of seaside encounters isn't an accident +- The explorer: the horizon. The literal fact of being able to see the edge of the world from where you're standing. The visual grammar needs to produce HORIZON + +The horizon is a Wow Moment the generator must not squander. A player who has spent their entire time in corridors and cargo bays and transit hubs who walks to the edge of a coastal settlement and sees ocean for the first time — that has to HIT. That has to be one of the big emotional beats of the game. + +The generator needs to treat water's edge as a LANDMARK equivalent. Not a blank space. The edge of the world. + +--- + +## The Edge Bleed Directive + +The lead said: edge bleed between districts is a directive. No hard grid boundaries. + +I want to stand up and cheer. + +This is exactly what I meant by "historical palimpsest." Real places don't have clean edges. The residential district bleeds into the commercial district through a row of buildings that used to be housing and are now shops and haven't fully committed to either identity. The industrial zone has a bar in it because the workers needed somewhere to go and nobody was going to build a proper commercial zone next to a freight yard. + +Edge bleed means the generator has to KNOW what it's blending. A residential/commercial edge is different from an industrial/maintenance edge. The visual grammar needs edge types the way it has zone types. "This chunk is 70% residential palette, 30% commercial palette, because it's the zone boundary and someone built a bakery here." + +The emotional payoff: players will navigate BY the bleed zones. "I'm getting close to the port district, I can feel it — the buildings are changing." That's orientation through spatial reading. That's what makes a place feel REAL. + +--- + +## Summary: What I'm Taking Into Round 3 + +**What's working:** +- Miri's cultural ingredients system is the answer to Second Station Syndrome. Keep it. +- Nigel's historical event seed is the answer to my historical palimpsest request. Keep it. +- Araminta's settled principle and LOS anchor rule are anti-dead-space gold. Keep them. +- Nigel's comparison test is the right success criterion. Keep it. + +**What needs expanding:** +- Gestalt's guarantees need to be reframed for ALL playstyles, not just investigation +- The visual grammar needs a language for OUTSIDE — planetary surfaces, farmland, ocean +- The generator needs to produce economic geography for tycoon gameplay (chokepoints, inefficiencies, political edges) +- The generator needs to produce romance geography (third places, ritual gathering, earned privacy) +- Backwaters need their own generation logic: small, dense in human entanglement, high cultural expression + +**What I'm fighting for in Round 3:** +- The grid must breathe. Non-rectilinear districts. Streets that curve. +- The horizon must exist. When you can see the edge of the world, it must feel like something. +- Backwaters must be complete, not failed hubs. +- Every generation sin I named in Round 1 applies to EVERY playstyle, not just investigation. Dead space is dead space. Predictable secrets are predictable secrets. Whether you're tracking a smuggling ring or falling in love, you need the world to feel real. + +The game is about asymmetric human awareness. That means the world must be WORTH being aware of, regardless of what you're trying to notice. diff --git a/docs/workshops/generator-architecture/ozzie-round3.md b/docs/workshops/generator-architecture/ozzie-round3.md new file mode 100644 index 000000000..055e2c6c1 --- /dev/null +++ b/docs/workshops/generator-architecture/ozzie-round3.md @@ -0,0 +1,214 @@ +# Generator Architecture Workshop — Round 3: Ozzie + +**Role:** Player Experience / Fan Perspective +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## The Lead Said "Both." Now Prove It. + +The lead mandated: some blocks grid, some organic chaos. That's the right answer. I'm not going to argue with the directive. What I AM going to do is describe what organic chaos FEELS LIKE from inside the game, so the team knows whether they've achieved it. + +--- + +## 1. Grid Breathing — What Would Actually Convince Me? + +Araminta gave me seven visual techniques to hide the grid: diagonal connectors, irregular setbacks, overhead elements crossing block edges, angled infrastructure, light territories, vegetation overflow, width variation. These are GOOD. I want all of them. But I need to be honest about what they actually do. + +They hide the grid. They don't remove it. + +A player who has been in five stations and five cities will develop spatial pattern recognition. Not consciously. They won't think "I see the 64m block structure." They'll think "this feels familiar." And they'll stop exploring as hard because some part of their brain is already mapping the space before they've seen it. + +**What would actually convince me:** + +Not straight streets. Not even diagonal streets. Streets that CURVE because the terrain required it. Streets that dead-end because someone built a wall across them. A block that's four-sided but not rectangular because it was wedged between two older streets that already existed. The block formed AROUND the streets, not the streets laid onto the blocks. + +That's what organic chaos looks like. It's not "our grid has visual camouflage." It's "the generator's planning unit can acknowledge that geography PRECEDES grid." + +**What I'd feel as a player:** I'd stop having a mental grid. Instead I'd have a mental web — this street goes roughly this way, and there's a building here that cuts across where I expected a street to be, and that alleyway I used before is definitely not where I thought it was. That disorientation is GOOD. It means I'm actually exploring, not filling in a pre-mapped skeleton. + +**What the team needs to confirm:** Can the infrastructure stage, which runs before block planning, produce street networks that are NOT grid-aligned? Even partially? Even in one quadrant of a district? Because if streets can curve at the infrastructure stage, the blocks that fill between them will naturally be non-rectilinear. The anti-grid emerges from the sequence, not from visual techniques applied after the fact. + +If the answer is "D-094 makes this architecturally hard," I want to hear that clearly. Then we decide together whether to defer, or whether we live with visual camouflage as our answer. But I need to know which one we're committing to. + +--- + +## 2. The Destruction Fantasy + +You've acquired a rocket launcher. You point it at a door. You pull the trigger. + +What do you EXPECT to find on the other side? + +Not "a reward." Not "the mission objective." Just — what does your body expect, before your brain gets involved? What is the PROMISE of a wall that can be broken through? + +The promise is: something was hidden. Something worth hiding. The wall isn't a game mechanic — it's a secret keeper. + +**When "nothing behind the wall" is acceptable:** + +When the nothing IS the secret. You blew open a wall and found an empty maintenance crawl that smells like it was used recently. There's a scuff on the floor and a torn piece of fabric caught on a conduit. Nothing valuable. Evidence of someone. That's not nothing. + +The generator produces a maintenance crawl behind that wall because maintenance crawls run behind things. But the wear marks and the fabric scrap — those are the historical event layer, the human detail layer, the layer that says "someone used this recently." Blank doesn't mean empty. Blank means unoccupied right now. + +**When "nothing behind the wall" is a letdown:** + +When what's behind the wall is geometrically identical to what was in front of it. You blow open a service corridor and find another service corridor that runs parallel to the one you were already in. Floors the same. Lights the same. Nothing different. Not even a story. + +That's not a wall you were meant to break through. That's the generator tiling space without thinking about what it means when those tiles become accessible. + +**The actual promise I'm making the generator:** Every space that can be accessed through destruction should be visually, spatially, or informationally distinct from the space that led to it. Even if it's empty. ESPECIALLY if it's empty. Because empty spaces tell stories through their emptiness. The empty room with a single chair facing the door tells a different story than the empty room with chairs knocked over and a broken light. The generator needs to produce meaningful emptiness. + +**The generation sin for destruction:** Blank geometry. Walls as collision mesh rather than architectural history. The moment I blow through a wall and find a room with standard floor tiles and standard lighting and no evidence that anyone has ever been there — the immersion cracks. I'm in a game. I'm not somewhere. + +**What I need from the generator:** When blocks are filled, is there a pass that considers "what if this space were accessed from an unexpected direction"? If every room is designed only from its intended access point, destruction reveals rooms that were never meant to be seen from the side. That's fine sometimes — a storage closet accessed from a blasted-open wall is a storage closet, and it looks like one, and that's correct. But the generator should be aware that its spaces will be seen from every angle, not just their intended entry. The historical detail layer (wear patterns, personal objects, evidence of use) needs to be present everywhere, not just near the official access points. + +--- + +## 3. The Skyscraper Fantasy + +You're standing at the base of a 50-floor building on a planet-side city. You look up. + +I know this is a top-down game. I know you won't render the exterior of 50 floors. I'm asking what that MEANS as a player experience. What does vertical promise when you're always looking from above? + +**What vertical actually gives you in a top-down game:** + +Not the view up. The view DOWN. The moment you get to a high floor and can see the ground-level spaces from above — that's the payoff. You've been navigating through those spaces, and now you see the whole of them at once. The chokepoints you navigated by feel now visible as chokepoints by sight. The building you couldn't quite see the back of — there's the back. There's the alley you didn't know about. + +The floor-above perspective is the detective's overview. It's the assassin's planning view. It's the tycoon seeing the whole market district. It's one of the best things a top-down game with vertical can do and I want to make sure we're designing FOR it, not accidentally getting it. + +**What makes the skyscraper feel real:** + +Every floor can't be the same. Floor 1 is public — retail, lobby, visible from street. Floor 5 has offices, different access rules. Floor 30 is where the building's ACTUAL business happens and has different security. Floor 50 is the executive level and it's emptier and more expensive and has windows looking out at the city. Each z-level is a narrative layer. The building is a social hierarchy expressed as architecture. + +The generator needs to know that UPPER FLOORS COST. They're harder to reach, which means reaching them means something. The assassin who gets to floor 30 has earned the information asymmetry of height. The tycoon who gets a meeting on floor 50 has bought social access with their economic leverage. The floors aren't decoration. They're gates with views. + +**Specific vertical surprises that matter:** + +- **The service elevator that goes everywhere** while the public elevator has floors it skips. This is one of the most powerful space discoveries in any game. The servant's passage. +- **The collapsed section** where floors 12-15 are inaccessible from the main stairwell because something happened there and nobody fixed it. But there's a maintenance route through floor 11 that still connects. +- **The exterior balcony** where you can see the adjacent building's internal courtyard — a space you weren't meant to see from outside. +- **The unexpected overlap** where two buildings share a floor because they were connected at some point and the connection was never fully removed. + +**The generation sin for vertical:** Floors that are identical except for the access tier gate on the elevator. If every floor is the same zone palette, same furniture density, same template — just with different lock levels — then vertical is just a difficulty gate, not a spatial discovery. Each floor needs to tell you something new about the building and the people who use it. + +--- + +## 4. The Assassin Fantasy + +I want to play an assassin. Not a detective. Not a tycoon. Someone who needs to put a specific person in the ground and leave without being connected to it. + +What does the generated world need to give me? + +**What I NEED:** + +**Sightlines from above.** Height equals safety for an assassin because it equals angles that defenders can't easily cover. The building that's three floors but has a section of roof that overlooks the target's regular lunch spot — that's a gift. The generator needs to produce height variation specifically in areas adjacent to social hubs. Not always. But sometimes. Often enough that I feel like I'm looking for it. + +**Crowd cover.** I can't move through an empty corridor without being seen. I need the market district when it's busy. I need the shift change crowd flooding out of the logistics hub. I need mass human movement that I can dissolve into. The generator's temporal NPC density variation (D-031 integration mentioned in Gestalt's guarantees) is ESSENTIAL for assassin gameplay. If NPCs are uniformly distributed across all hours, every crowd is the same crowd. I need the 8pm market rush and the 3am empty corridors to be different game states. + +**Approach distinct from escape.** This is my deepest need and probably the hardest to generate. I need to approach the target through one route and escape through a different one. If the district only has one logical way to reach the target location, I'm caught whether I succeed or fail. The generator must produce multiple-path topology that allows approach-via-one-path, escape-via-another as a spatial guarantee. This is Gestalt's Encounter Corridor archetype, but I need at least two of them pointing at every major social hub from different directions. + +**Timing windows.** I'm not just looking for WHERE the target is. I'm looking for WHEN the target is somewhere without witnesses. The generator's daily rhythm system — the shift-based NPC schedules, the social hub peak hours — needs to create moments when specific NPCs are in specific places with reduced ambient traffic. I'm not asking the generator to write my assassination plan. I'm asking it to produce a world where those windows exist and can be discovered. + +**The assassin generation sins:** + +**Sin #1: Omnidirectional witness coverage.** If every location in the target's routine is surrounded by NPCs who would notice an incident from every angle, assassination is impossible without a social trust level I may not have. The generator must produce blind spots — structural, temporal, or social. Places and times where the math works for me. + +**Sin #2: Escape routes that all converge.** One district entry/exit point defeats assassin gameplay. Even if I get the target clean, I'm identified at the only gate on my way out. The generator must produce districts with multiple access patterns — not just the official gate, but the maintenance exit, the neighboring district's connection, the emergency access that's technically locked but practically isn't. + +**Sin #3: No vertical option.** A flat district is an assassin's nightmare. Every position is visible from adjacent positions. There's no height advantage. The cover is all horizontal. The generator needs to guarantee at least one elevated access point per district — a walkway, a second-floor balcony, a roof connection — that provides a different plane of sightlines. + +**Sin #4: No crowd rhythm.** If the district is always equally busy, I can never rely on cover. If it's always equally empty, I'm always exposed. I need the district to have a SOCIAL CALENDAR that I can learn and exploit. + +**What makes the assassin's game exciting:** The district is a puzzle I have to solve under time pressure without revealing that I'm solving it. And the puzzle changes every time because the target's schedule, the crowd timing, and the chokepoints all came from a seed. The assassin's gameplay is the detective's gameplay in reverse — instead of finding who did it, I'm designing a situation where what I did is undiscoverable. + +--- + +## 5. Mobile Environments + +You board a train. A ship. A spaceship. You're in motion. + +What's the best version? What's the worst? + +**The worst version:** + +A rectangle. Chairs. Maybe a window. Nothing to do while you wait to arrive. The mobile environment as a loading screen with a bed in it. If the only reason to be on this train is to get to the other end of the track, then the train is a liability — it's time I'm spending not doing things, in a featureless box. + +**What makes mobile environments actually exciting:** + +THE SOCIETY IS COMPRESSED. On a train car, you have a cross-section of whoever was going the same direction on the same day. The dockworker and the corporate auditor and the family visiting relatives and the person who's clearly nervous about arriving. They're all stuck together. For a fixed duration. They can't leave. + +This is a CRUCIBLE. The information that emerges in a compressed mobile space is different from what emerges in a fixed location because people are in transition — they're leaving one context and not yet in the next. They're between their roles. Someone going home from work is a different version of themselves than they are at work or at home. The train is where you catch people mid-transformation. + +**For the investigation player:** The suspect is on this train. You have three hours before arrival to either find what you need or get close enough that arrival means something. Time pressure plus a bounded social space plus people in transitional psychological states — this is an investigation goldmine. + +**For the romantic player:** You meet someone on the train. You have the journey. When you arrive, you might never see them again. The journey IS the relationship. The finite space creates intimacy. + +**For the assassin:** Someone important is on this train and can't leave it. The closed environment is either a trap or an opportunity. + +**For the tycoon:** The other passengers are future contacts, competitors, people who know things. The train car is a mobile networking event. + +**What the generator needs to produce:** Mobile environments as distinct social worlds with their own rules. Not smaller versions of fixed locations. The train car has its own access topology (which seats are private, which are communal, where the conductor circulates), its own social norms (you don't talk to strangers unless the journey is long enough), its own timeline (approaching destination changes behavior). + +**The ship crossing ocean:** Longer duration means deeper social development. The people on this ship have been together long enough that relationships have formed, tensions have built, small political structures have emerged. Arriving at port is a disruption of a miniature society. That's powerful. + +**The spaceship between systems:** This should feel like the longest journey. The tightest social compression. The highest stakes — because whatever happens between departure and arrival, you can't get off. The information landscape on an in-transit vessel could be incredible. Secrets that only exist in the between-state, before arrival collapses them into the next destination's reality. + +**The mobile environment generation requirement:** Time as a spatial dimension. The space is fixed; the SOCIAL STATE OF THE SPACE changes over the duration of the journey. The generator needs to produce not just the physical vessel interior but the social arc — who will have what conversation by the time you arrive, what tensions will have formed, what information will have surfaced. The journey is the content. + +--- + +## 6. Not Every Place Is For You + +You're an assassin. You arrive at a farming settlement. There's no obvious target. The rhythms here are agricultural. People are talking about harvest yields and drainage issues and someone's eldest daughter who left for the city. + +Is this boring? + +NO. And here's why. + +**The mismatch is the content.** I am visibly wrong for this place. Every social interaction I have is filtered through "you're not from here." The assassin's toolkit — reading social hierarchies, finding the informal power structure, identifying who knows what — is ENTIRELY applicable to a farming settlement. The hierarchy here is different. The power is in land tenure and water access and who the community patriarch is. The secrets are different. The leverage is different. + +But the SKILLS transfer completely. + +**The farming settlement forces the assassin to slow down.** I can't rush through this space. I can't extract value quickly because I'm not trusted. The information I need is inside relationships I don't have yet. I have to EARN my way into the community's information landscape. And the community can TELL I'm in a hurry, which makes them trust me less. + +This is actually harder for the assassin than the station district. The station district has strangers. I'm just another stranger. The farming settlement has known each other for thirty years. I'm the stranger. I'm the EVENT. + +**What the "not every place is for you" experience produces:** + +It makes me understand the world better. It makes me feel the social geography of this universe — that different places have different rules, and I can't apply the same playbook everywhere. A farming settlement IS NOT A FAILED STATION. It's a complete world with its own social architecture. The assassin who arrives here expecting a station and gets a community is confronting the reality that THIS IS WHAT THE UNIVERSE ACTUALLY CONTAINS. + +The generation sin would be making the farming settlement too empty to have any social architecture at all. If there are four NPCs with nothing to say to each other, of course the assassin is bored. But if there's a complete small society — the family with the land dispute, the newcomer who married into the community, the elder who remembers when things were different, the teenager who wants to leave — then there's a social web the assassin can read and engage with on its own terms. + +**What this means for the generator:** Complexity should scale with population and establishment, not with distance from the main narrative. A backwater farming settlement with 80 people who've been here for forty years should have a DENSER social graph per capita than a transit hub with 2000 transient workers. The drama isn't smaller. It's closer. It's personal. + +**What the mismatch teaches the player:** That their playstyle is a lens, not a filter. The investigator doesn't find nothing at the farming settlement — they find a different KIND of mystery. The assassin doesn't have no targets — they find that "targets" here don't mean the same thing they mean in the city. The game is smarter than your playstyle. The playstyle is the starting assumption the world eventually corrects. + +THAT is asymmetric awareness. Not "different players see different things." Players discover that what they were looking for is a simplification of a richer reality. + +--- + +## Summary: What I'm Confirming, What I'm Still Fighting For + +**Confirmed from Round 2 that I love:** +- The two-phase generation. Background prep as district personality; on-demand as physical space. YES. The personality exists before I arrive; the physical manifestation of it generates as I move through it. +- The flavor type → NPC pattern weight shift. Market stalls bring different people. That's social variation through spatial fill. +- The era_cause fields. LShape buildings with a recorded reason for their shape. Historical palimpsest achieved. +- The 7 universal spatial archetypes. Every playstyle finds the space it needs. Confirmed. +- The `ComplexityTier` — gameplay guarantees only for Full. A farmland district doesn't need a surveillance chokepoint. Correct. + +**Still fighting for:** +- The grid breathing answer. Araminta's seven techniques are good camouflage. I want to know if the streets can curve at the infrastructure stage. Give me that answer in the D-record. +- The horizon as a mandatory landmark reservation. Not just a visual palette. An actual generator slot that says "this district has a water edge and the water edge is a discovery moment." +- Destruction revealing meaningful spaces. The historical detail layer needs to cover the whole space, not just the intended entry paths. +- Mobile environments as time-compressed social worlds, not loading screens with chairs. + +**New principles I'm proposing for the D-record:** + +1. **Every space is seen from every angle, not just its intended entry.** Destruction and vertical access reveal spaces from unexpected directions. The detail layer must be uniform. +2. **The playstyle is the starting assumption the world eventually corrects.** Mismatch between player expectation and settlement type is content, not failure. +3. **The horizon is a generated landmark.** Water's edge is reserved at district planning, not incidental. +4. **Mobile environments have a social arc, not just a physical interior.** The journey is content; the space changes state over time. + +--- + +*I have loved this workshop. This team is building something real. Don't let it just be a detective game with extra rooms. Make it a world.* diff --git a/docs/workshops/generator-architecture/ozzie-round4.md b/docs/workshops/generator-architecture/ozzie-round4.md new file mode 100644 index 000000000..1a48c1ff9 --- /dev/null +++ b/docs/workshops/generator-architecture/ozzie-round4.md @@ -0,0 +1,191 @@ +# Generator Architecture Workshop — Round 4 (Final): Ozzie + +**Role:** Player Experience / Fan Perspective +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## The Fan's Verdict + +I've read the notes. Three rounds of brilliant people building something real. Now you need me to close the loop as the PLAYER. No more open questions. Here are my verdicts. + +--- + +## OQ-R4-A: The Vessel Question — My Verdict + +**YES. Vessels must persist as world entities. Tyre's model wins.** + +Let me tell you exactly why, from the player's gut. + +You arrive at a coastal spaceport. You came on a ship. You disembark. You walk out into the port district. And somewhere behind you, there's a berth. + +**Is the ship still there?** + +YES. It has to be there. Not because of architecture. Because of BELIEF. + +If I walk back toward the dock and the berth is empty — or worse, if there's a *different* ship there — something breaks. The world revealed itself as a stage set. The journey I just lived wasn't real. It was a content module that got unloaded when I was done with it. The crew I got to know doesn't exist anymore. The cabin where I found the incriminating letter is gone. The suspicious passenger I was watching during the crossing — dissolved. + +This game is built on asymmetric information. It's built on the idea that things are REAL and I have incomplete access to them. The moment a ship dissolves when I leave it, that premise collapses. If the world only exists where I'm looking at it, I can't trust anything I know about it. + +**The ship at the dock is a trust signal.** It tells me: this happened. That voyage was a real event in a real world. The people on that ship still exist somewhere. The things that occurred during the crossing had consequences that are still active. + +For the investigation player especially: evidence from the journey might require going BACK to the ship. A name I heard, a face I saw, something in a cabin I didn't get a good look at — the ship being there means that thread is still pullable. + +For the assassin: the target was on the ship. The ship is still docked. The target hasn't been able to leave yet because the connecting transport isn't until tomorrow. THE SHIP BEING THERE IS THE CONTINUATION OF THE CONTRACT. + +For the romantic: the person you met is still on the ship, packing their bags, and you have a window. + +**What "persist" actually means:** + +The ship doesn't need to be frozen in the exact state I left it. Cabins get cleaned. Cargo gets moved. But the SHELL is there and the CREW is there and the DEPARTURE SCHEDULE is real. The ship stays at dock until its schedule says it leaves. Then it's gone — legitimately, procedurally gone, because it sailed. That's different from "it dissolved when the player stepped off." + +The architectural cost is real. Nine-and-a-half dev-days versus Nigel's cheaper solution. That cost is worth it because the vessel persistence is load-bearing for the game's fundamental promise: this is a real world, not a sequence of content modules. + +**Verdict: MobileChunk as entity-carried world entity. Tyre's model. Not negotiable.** + +One addendum: the generator must also produce departure windows. Mobile environments that persist at dock without departure schedules turn every port into a ship graveyard. The vessel arrives, docks for N hours or days based on its route and cargo, and departs. That departure schedule is part of the world — and it creates URGENCY for players who want to get back on board. + +--- + +## OQ-R4-F: Does Destruction Feel Caused? — My Verdict + +**Not yet. XOR-seeded regeneration alone is not enough.** + +Here's the test I'd run as a player: + +I arrive in a district after the gas explosion event. I walk in from the east side. What do I see? + +If XOR seeding produces random variation — meaning the worst damage could be anywhere, rooms near the source might be pristine, rooms far away might be rubble — then what I experience is *different*, not *caused*. I can't point to where it happened. I can't understand it as an event. It's just: this district now looks like this. + +**That fails my principle.** Destruction must be caused. The player must be able to read the aftermath and understand the event that produced it. The epicenter should be identifiable. The damage gradient should radiate outward. The structural logic should be visible: load-bearing walls that faced the blast collapsed; walls behind other walls are intact; the roof went first in the affected zone; the floor is scorched concentrically. + +XOR-reseeding produces variation. It doesn't guarantee that the variation is *spatially coherent with the event source.* + +**What I need:** Constrained soft re-generation, not pure XOR. The event has: +- A source location (specific tile or zone) +- A damage type (blast, fire, flood, collapse) +- An intensity (moderate, severe, catastrophic) + +The re-generation should be parameterized by these. The seed varies, but the variation is *bounded by event physics.* Rooms adjacent to the explosion source always show blast damage. Rooms two districts away from a flood show water line staining, not fire damage. + +Araminta's five visual stages (Active → Fresh Aftermath → Stabilized → Reconstruction → Healed Scar) are the right temporal vocabulary. But they need to be applied with spatial gradient from the source, not uniformly across the district. + +**Practically:** I'm not asking for a full physics simulation. I'm asking for: when the event is generated, it stamps an epicenter and a radius. Everything within that radius gets heavy modification. Everything in the ring outside gets lighter modification. Everything beyond that is atmospheric (NPCs talking about it, smoke visible on the horizon, minor debris at the edge). The XOR seed varies the details within each zone; the zones themselves are determined by event parameters. + +**The gap between "different" and "caused" is the difference between a world with history and a world with random states.** + +If you can show me a gas-explosion district where I can stand at one point, look around, and say "IT HAPPENED HERE" — then the destruction feels caused. If I'm guessing, it's not there yet. + +--- + +## Rooftop Bar — My Verdict + +**Sometimes a bar. The variety is the point.** + +The guarantee says tall building rooftops must be Insider or BreachOnly. A rooftop bar IS Insider — you have to know it exists and find the access. So the guarantee doesn't conflict with this. It just means the rooftop discovery can be EITHER. + +Here's what I want as a player: + +**Most rooftops I find:** Maintenance access. Equipment arrays. Maybe a view. The reward is the vantage point — you've earned height, and height gives you the overview. That is its own payoff. + +**Some rooftops I find:** IT'S A BAR. Someone put tables up here. There are people. There are drinks. There's ambient sound. There's the WHOLE CITY VISIBLE BELOW and someone chose this as the place to be social. The contrast with the maintenance-access rooftop makes this more exciting, not less. If every rooftop were a bar, it would be expected. It's the RARITY that creates the "oh" moment. + +**The discovery experience:** + +Finding a restricted rooftop: tactical satisfaction. I got somewhere most people can't. The view is mine. + +Finding a rooftop bar: social disruption. I came up here expecting to be alone with the view and found a SCENE. Suddenly this is an information space — who's up here? Why here? Who chose this location for this social event? What conversations are happening above the city that can't happen on the street? + +The rooftop bar is a generator surprise — it tells me the world is richer than I expected. It breaks my assumptions about what "high floors = restricted" means. Some high floors are restricted because they're exclusive. Some are restricted because they're operational. Some are restricted because they're explicitly SOCIAL and you weren't invited. + +**Verdict:** Generator should produce a minority of rooftops as Insider social spaces (bars, gardens, event venues). Not a specific percentage — just "some." The guarantee system says the roof exists and is Insider or BreachOnly. The content type is generator variation. Rooftop bars exist. They're not common. Finding one is a moment. + +--- + +## Fan Validation: All 12 D-Ready Items + +Reading these as a player. Does anything sound wrong? Does anything excite me? Is anything missing? + +**1. DistrictLayoutMode — Grid and Organic** +EXCITING. The city that grew vs the city that was planned. I want to feel the difference when I arrive. Grid tells me who's in charge here. Organic tells me how long this place has been here. I will feel this. + +**2. Guarantee Tier System** +Not exciting, but ESSENTIAL. Without this the generator is just hoping. With this the generator is promising. I want the generator to make promises it keeps. + +**3. TrianglePurpose Enum with Tactical** +YES. The tactical triangle (Target + Protector + Informant) is the assassination contract made spatial. This is what it looks like to generate "I have a contract here." The world knows there's a target before I do. The generator already made the triangle. I'm just discovering its shape. + +**4. WallBackside / TileBehindState with BreachOnly** +EXCITING. Every wall is now a promise. The ServiceVoid specifically — the conduit space between walls — is one of the most game-feeling things I've heard in this workshop. A space that's only accessible by going through walls? That is IMMERSIVE SIM. That is "I found a way that wasn't meant to be found." + +**5. Dynamic Modification via Overlay** +Correct and essential. But I want to note: the world having a generator state and a delta layer means the world has HISTORY. Not just current state. The delta layer is the record of what has happened since generation. That is incredibly powerful for investigation — you can read the delta and infer events. This is one of the best architectural decisions in the entire workshop. + +**6. ZonePalette Modifier System** +Good. Mostly invisible to me as a player but I'll feel it as "this farmland looks different from that farmland." I trust the team to make the combinations interesting. + +**7. Horizon View Corridor** +I fought for this in Round 2 and I'm glad it made the D-ready list. The mandatory water-edge viewing moment is NON-NEGOTIABLE. It is one of the primary Wow Moments. A coastal district without a clear line of sight to water is a failure mode. The reservation prevents this. + +**8. Assassin Lens Spatial Guarantees (A-1 through A-4)** +HELL YES. These are promises to me. The generator is promising: there will be an elevated position. There will be multiple egress routes. There will be a timing window. There will be a path that doesn't cross high security. These aren't design preferences. These are contract terms. The assassin player signed a contract with the generator and the generator will honor it. + +**9. Heritage Grammar Overlay for Non-Urban Palettes** +Exciting in the way deep systems are exciting — I may not consciously notice it, but I'll feel it. A Tide-heritage fishing village is arranged differently than a Frost-heritage logging settlement. That's cultural legibility in space. I want to arrive somewhere and feel the specific history of it. + +**10. Non-Urban Informal Zone Typology** +Important but mostly felt through gameplay rather than seen directly. The key insight — what happens on the boat is the crew's business — is the kind of rule I'll feel as a player when I realize I've been having conversations in a zone where there are no institutional consequences. That's very powerful for information gathering. + +**11. Vertical Scale Architecture** +VERTICAL IS THE GAME. Everything Gestalt and Tyre built here — z-bands, lazy loading, the access tier gradient, the mandatory discovery zone at the roof — this is the immersive sim in architectural form. The building that is itself a puzzle. Floor 30 having information that floor 1 can't have because floor 30 is harder to reach. YES. All of this. + +**12. Trauma Events as EraModification Subtypes** +Essential for history. The delta layer + the trauma subtypes together mean the world can have scars with specific causes. I can arrive at a district and read what happened to it — not just "this place is damaged" but "this place had a violence event and the heritage root of its population is showing me exactly how they responded." That's asymmetric awareness applied to history. + +--- + +## What's Missing — Things That Would Make Me Feel Incomplete + +Two things I don't see in the 12 D-ready items that I believe need to be added: + +**Missing 1: The Departure Schedule Model** + +If vessels persist at dock (my verdict above), the generator needs to produce departure windows. Not just "the ship is there" — but "the ship is there for 36 hours and then it leaves." The departure schedule is part of the world's temporal structure. It creates natural urgency. It means the player decision to re-board (or not) has a deadline. Without departure schedules, ports become ship graveyards and the vessel persistence loses its urgency. + +This may already be implied somewhere in Miri's transit social grammar, but I didn't see it stated as a generator output. It needs to be. + +**Missing 2: The Mobile Environment Social Arc** + +I named this in Round 3 and it still isn't in the D-ready list. Miri's transit social modifier is a fantastic cultural grammar. But the JOURNEY itself — who talks to whom, what information surfaces at what point in the crossing, what tensions emerge as arrival approaches — needs a structural representation. + +Not complex. Just: the mobile environment has a timeline. Certain events are seeded to occur at certain journey-stages. The passenger manifests are generated at journey-start. But the social arc — what changes about those passengers' relationships by the time you arrive — needs to be a generator output, not an emergent accident. + +The journey is the content. If the social arc isn't structured, the content is random rather than authored. I want to know: by hour 6 of a 12-hour crossing, what was this generator PLANNING to have happen? That plan is the social arc. + +--- + +## Summary + +The generator is almost there. What excites me: + +- The world is real and persistent (Tyre's vessels win) +- Every wall is a secret keeper (WallBackside + BreachOnly) +- Destruction has history (delta layer + trauma events) +- Height has meaning (vertical scale + view down) +- Every playstyle has guaranteed affordances (the audit system) +- The journey is content (mobile environments as social pressure cookers) + +What I'm still watching: + +- Soft re-generation needs event parameters, not just XOR seeds — destruction must look caused +- Departure schedules need a D-record +- The mobile environment social arc needs a D-record + +The 12 items are good. Add the two missing ones and this generator is making promises worth keeping. + +**THAT is the game. Build it.** + +--- + +*Round 3 closing note stands: Don't let it just be a detective game with extra rooms. Make it a world. We're closer than we were. This round is proof.* diff --git a/docs/workshops/generator-architecture/ozzie-round5.md b/docs/workshops/generator-architecture/ozzie-round5.md new file mode 100644 index 000000000..75d6fff6f --- /dev/null +++ b/docs/workshops/generator-architecture/ozzie-round5.md @@ -0,0 +1,46 @@ +# Generator Architecture Workshop — Round 5 (Final Review): Ozzie + +**Role:** Player Experience / Fan Perspective +**Date:** 2026-02-27 +**Workshop:** Generator Architecture (#562) + +--- + +## Sign-Off + +The outcomes document is good. "What This Generator Promises the Player" uses my language, captures my principles, and would make me want to play this game. The vessel persistence verdict is correctly stated. The XOR prohibition is correctly grounded — epicenter + radius + intensity means the damage radiates from a specific point, which is exactly the "IT HAPPENED HERE" test. The section reads like a generator that keeps its promises. + +**I sign off on:** +- "What This Generator Promises the Player" — correct and exciting +- L-4 (entity-carried MobileChunk, vessels persist) — correct verdict +- L-7 (XOR prohibited, DamageOverlay mandatory) — correctly stated, correctly reasoned +- All 14 D-ready items — the player experience dimension is correctly captured on all of them +- The NPC model (10 axes + Ysabel Vorn litmus test) — the one-NPC-five-lenses proof works +- Departure schedules captured in D-READY-13 — correct +- Mobile social arc in Q-NNN-c — correct to flag for sprint + +--- + +## One Correction + +**D-READY-11 (Vertical Scale) — Rooftop Bar Clause:** + +Current text: *"Heritage root determines which config is assigned."* + +This is wrong. Heritage root should INFLUENCE the probability — not determine the assignment. + +If heritage root fully determines whether a rooftop is a bar or restricted, then experienced players can predict rooftop type from street-level observation. They'll learn: Tide buildings have bars, Frost buildings are restricted. The discovery moment dies. Finding a rooftop bar stops being a surprise and becomes recognition. + +Suggested fix: *"Heritage root weights the probability distribution between `Restricted` and `PublicWithHiddenLayer`, but the final config is seeded per-building. A minority of buildings of any heritage root must be configurable as the non-dominant type."* + +This preserves: the cultural tendency (Tide communities tend toward public rooftop spaces), while allowing: the seeded exception that makes discovery real (a Frost-heritage warehouse with a secret rooftop bar is more memorable than a Tide-heritage café with one because you didn't expect it). + +The discovery layer mandatory in both cases — that part is correct. Keep it. + +--- + +## Nothing Else to Flag + +The document is structurally sound from a player experience perspective. No other descriptions read as "technically correct but emotionally flat." The generation sins I named across all four rounds are either guaranteed-against or explicitly flagged. The assassin's contract (Tactical triangles + A-1 through A-4) is in writing. The world is real. The walls are secret keepers. The destruction has an epicenter. + +**Build it.** diff --git a/docs/workshops/generator-architecture/round-1-notes.md b/docs/workshops/generator-architecture/round-1-notes.md new file mode 100644 index 000000000..ef6953db2 --- /dev/null +++ b/docs/workshops/generator-architecture/round-1-notes.md @@ -0,0 +1,344 @@ +# Generator Architecture Workshop — Round 1 Notes + +**Compiled by:** Qatux (Documenter) +**Date:** 2026-02-27 +**Source files:** +- `docs/workshops/generator-architecture/gestalt-round1.md` +- `docs/workshops/generator-architecture/tyre-round1.md` +- `docs/workshops/generator-architecture/miri-round1.md` +- `docs/workshops/generator-architecture/araminta-round1.md` +- `docs/workshops/generator-architecture/nigel-round1.md` +- `docs/workshops/generator-architecture/ozzie-round1.md` + +--- + +## Overview + +Round 1 produced six independent domain assessments of the generator architecture. Despite writing without cross-referencing each other, participants arrived at strong consensus on the pipeline model and the role of authored templates. The primary open tensions are in pipeline stage ordering, the depth of variation the quarter system will actually deliver, and the question of whether cultural variation translates mechanically (or remains decorative). + +--- + +## Section 1: Consensus Points + +### C-1: The Pipeline Model + +All participants implicitly or explicitly accepted the Cities Skylines top-down pipeline as the working model: + +``` +Geography + → Infrastructure (transport, utilities, Meridian coverage) + → Amenities & Services (social site type selection) + → Population (NPC density, entanglement seeding) + → Zoning (access tier assignment per zone) + → Block Generation (chunk merge strategy, multi-block reservations) + → Chunk Fill (D-025 template instantiation, sub-chunk quarters, NPC placement) +``` + +**Sources:** Gestalt §3, Miri §5, Nigel §2, Tyre §5.3 pipeline table. + +Each participant mapped their domain to this pipeline and found it accommodated their requirements without modification. No participant proposed a different top-level structure. + +--- + +### C-2: Spatial Hierarchy (Confirming D-094) + +All technical discussion respected the four-level hierarchy without question: + +| Level | Sim tiles | Visual tiles | Physical | Role | +|-------|-----------|--------------|----------|------| +| Chunk | 64×64 | 32×32 | 32m | Streaming + serialization unit | +| Block | 128×128 | 64×64 | 64m | Generator planning unit (4 chunks) | +| District | 512×512 | 256×256 | 256m | Template composition unit (16 blocks) | +| Quarter | 32×32 sim | 16×16 visual | 16m | **Fill-time constraint only — not a hierarchy level** | + +The quarter clarification is important: Tyre stated explicitly that quarters are a generation-side layout concept, invisible to the runtime after fill. The server sees tiles; quarters exist only during the generation pass. Araminta and Nigel both accepted this framing. + +**Sources:** Tyre §1.4, §4.4; Araminta §2.1; Nigel §3. + +--- + +### C-3: "Templates Are Authored; Placement Is Generated" + +Stated independently by three participants in nearly identical terms: + +- Tyre: "D-025 templates are authored. Skeleton placement is generated. The generator arranges templates, not tiles." +- Miri: "D-025 templates are bricks. The district skeleton is the architectural plan. The generator writes architectural plans from ingredients; human authors craft the bricks. The generator never touches the bricks themselves." +- Gestalt: "The generator places the slots; templates fill them." + +This principle governs the generator's relationship to the entire D-025 social site library. It is the mechanism that preserves hand-authored content quality while enabling procedural arrangement. + +**Sources:** Tyre §2.3, Miri §5 (Q-036 reconciliation), Gestalt §3. + +--- + +### C-4: District Skeleton as Atomic Generator Output (Q-036 direction) + +Strong convergence: the district skeleton is the generator's primary compositional output. Tyre proposed a concrete Rust data structure (`DistrictSkeleton`). Miri endorsed the skeleton concept with a complementary description of its contents (society profile → social site slots + spatial positions + access topology + NPC capacity + triangle assignments). Gestalt mapped each of its seven spatial guarantees to specific fields of the skeleton. + +No participant proposed an alternative atomic unit. + +**Sources:** Tyre §2, Miri §5 (Q-036 reconciliation), Gestalt §2 and summary table. + +--- + +### C-5: Grey Economy as Negative Space in the Generator + +Three participants independently required the generator to model what official zoning does NOT account for: + +- Gestalt (Guarantee 2): Every district must have at least one zone with `meridian_coverage: degraded` or `minimal` — explicitly generated, not incidental. +- Miri: "The grey economy occupies the spaces that official zoning doesn't account for. The generator must model what's NOT in the official map — which corridors are maintenance-only, which zones have dead spots, which blocks have unofficial access routes. This is not flavor; it's where the investigation happens." +- Ozzie: "A place I'm not supposed to be" is a non-negotiable player experience requirement. Spaces that "feel like I wasn't meant to find it." + +Araminta adds a visual corollary: maintenance/service zones must always have at least one empty quarter per block of service access type, and the dark/sparse visual treatment that distinguishes them. + +**Sources:** Gestalt §2 Guarantee 2, Miri §5 point 4, Ozzie "What I Need" §2, Araminta §3.2. + +--- + +### C-6: Cultural Variation Must Produce Mechanical Variation + +All six participants required that the society profile parameters (Q-032's six categories) translate into gameplay outcomes, not just aesthetics: + +- Miri: "If cultural variation doesn't translate to mechanical variation... then 300 distinct cultural profiles produce only the illusion of variety." +- Gestalt: Cultural variation produces "different investigation difficulty, specific NPC behavior patterns, and specific contraband moral texture." +- Nigel: The cultural layer "has the highest variety payoff per authored ingredient" and trust-building timelines, access tier thresholds, and NPC pattern distributions must all vary by culture. +- Ozzie: "If the generator is just varying layout geometry and art themes, the 50th station is just the 50th palette swap. If it's varying the SOCIAL AND POLITICAL TEXTURE — who's in charge, what they want hidden, what grudges are still warm — then I will happily explore the 100th." + +The mechanism: `privacy_level` and `trust.building_rate` are the key translation parameters (Miri). Cultural parameters feed NPC behavior systems; they cannot be a pure naming/art variable. + +**Sources:** Miri §1 and §4, Gestalt §3 amenities stage, Nigel §2 Stage 3, Ozzie "What Makes the 50th Station Exciting." + +--- + +### C-7: Transit District as Generator Validation Fixture + +Two participants independently proposed expressing the v0.1 Transit District (D-093) as generator output to validate the schema: + +- Tyre §5.4: "Express the v0.1 Transit District as a hand-authored DistrictSkeleton + hand-authored ChunkData for each of its 64 chunks." This validates the schema, creates a test fixture, and validates the content pipeline. +- Gestalt §5 (Q-C): Proposed the "minimum viable district" — 1 workplace, 1 bar, 1 maintenance spine, 1 transit node, 1 restricted zone, 2 active triangles — as the minimum that satisfies all seven spatial guarantees. + +These are complementary: Tyre's is a schema validation exercise; Gestalt's is a gameplay completeness check. Together they form a complete validation plan for the generator's district output format. + +**Sources:** Tyre §5.4, Gestalt §5 Q-C. + +--- + +## Section 2: Open Questions (for Round 2) + +### OQ-1: Pipeline Stage Ordering — Population Before or After Zoning? + +**The tension:** Gestalt says NPC secrets must have plausible staging grounds before NPCs can be validly generated, creating a feedback loop between Population and Zoning. Miri places template instantiation at the district skeleton stage (after Zoning, before Block Generation). Tyre's pipeline table places NPC Population as the last stage (v0.3+), after Chunk Fill. + +These three descriptions are not fully compatible. The ordering of Population relative to Zoning and Block Generation is unresolved. + +**Gestalt's position:** Population follows zoning for assignment, but population requirements constrain zoning (feedback loop required). +**Miri's position:** Triangle assignments happen at the district skeleton stage, after zoning but before block generation. +**Tyre's table:** NPC Population is last — a v0.3+ implementation concern. + +**For Round 2:** Tyre needs to address whether the feedback loop Gestalt describes creates implementation complexity. Can the skeleton stage partially resolve NPC type requirements (without full NPC generation) to guarantee spatial staging grounds exist? + +**Raised by:** Gestalt Q-A; Miri §5 "when D-025 templates get instantiated"; Tyre §5.3 pipeline table. + +--- + +### OQ-2: Seed Architecture — Single Master Seed or Per-Stage Seeds? + +Nigel asks explicitly: "Is it a single master seed that derives all sub-seeds deterministically, or does each stage have its own seed parameter? I need to know whether 'same seed, different character selection' produces the same world with different lenses, or genuinely different worlds." + +Tyre mentions determinism (D-010) but does not address how the seed propagates through stages. + +**Stakes:** If same seed + different character = same world with different lenses, then character choice is a filter on the same information space. If same seed + different character = different worlds, the seed architecture is more complex and the reproducibility requirement (Q-030) more demanding. + +**For Round 2:** Tyre to specify seed propagation architecture. + +**Raised by:** Nigel §1 Guarantee 3, §7; Tyre §5.3 (mentions determinism, not seed propagation). + +--- + +### OQ-3: Can the Quarter System Produce Social Variation, Not Just Visual Variation? + +Ozzie raises this directly: "Does the choice of what fills a quarter have downstream consequences — does a garden quarter mean something different about who lives nearby versus a shack quarter? If the quarters are just aesthetic choices, I'll see through them on the second station." + +Nigel endorses the quarter system as a replayability engine (§3) but focuses on physical geometry variation (chokepoints, routes). Ozzie wants to know if the fill type has social meaning. + +**The question for Round 2:** Does the flavor structure assignment in unclaimed quarters (Nigel §4) feed back into NPC generation? A "market stall" quarter should presumably attract different NPC types than a "Commission kiosk" quarter. Is that dependency in the generator's data flow? + +**Raised by:** Ozzie "Quarter System" section; Nigel §4. + +--- + +### OQ-4: Historical Palimpsest — Can the Generator Produce Layered History? + +Ozzie's most demanding requirement: "Can it make stations that feel like they were built in layers by different people with different plans?" She distinguishes CAUSED irregularities (this building is L-shaped because something made it this way) from RANDOM irregularities (the RNG said so). + +Miri's era-stratification system (Era 1/2/3) and historical event modifier pass are the proposed technical mechanisms. Nigel proposes historical events as a seed modifier leaving "physical traces." + +**The unanswered question:** What specific causal relationships does the generator encode? When the generator produces an L-shaped building, does it record WHY that shape exists, and can that reason surface as environmental storytelling? Or is the L-shape purely geometric, with the player inventing the reason? + +**Raised by:** Ozzie "Hand-Crafted vs Obviously Procedural"; Miri §2 "Historical events"; Nigel §5 Layer 4. + +--- + +### OQ-5: Society Profile YAML as Serde-Compatible Schema + +Miri asks Tyre directly: "Can the content pipeline consume the society profile YAML format (from wiki-review R4) as a serde-compatible schema? The parameter depth is significant." + +This is a technical feasibility question. The society profile has six categories, with blend weights, NULL states, and nested parameters. It needs to be consumable by the Rust pipeline. + +**Raised by:** Miri §5 "For Tyre." + +--- + +### OQ-6: Era Stratification in Chunk Data Structure + +Miri asks Tyre: "How does era-stratification map onto the chunk data structure? The Z-level model (D-093) is confirmed, but does the chunk system have era fields?" + +Tyre's `DistrictSkeleton` has a `zone_palette: Vec<ZoneDefinition>` field, which presumably could carry era tags. But this is not made explicit in Tyre's Round 1. Araminta's visual rules depend heavily on era tags being present at block level before chunk fill runs. + +**Raised by:** Miri §5 "For Tyre"; Araminta §1.3. + +--- + +### OQ-7: Size of the Cultural Ingredients Space + +Nigel asks Miri: "How large is the cultural ingredients space? (Q-032 specifics.) The variety payoff of the cultural composition layer depends entirely on how many distinct ingredient combinations produce distinguishable district personalities." + +Miri provides a conservative estimate ("comfortably exceeds 300 meaningfully distinct societies") but does not enumerate the combination count. Nigel's own calculation (300 worlds × 2 characters × 20 distinct cultural compositions = 12,000 meaningfully distinct games) depends on the "20 distinct compositions" assumption, which may be conservative or generous. + +**Raised by:** Nigel §7; Miri §1 "How 300 worlds get variety." + +--- + +### OQ-8: Visual Vocabulary for Flavor Structure Categories + +Nigel proposes six flavor structure categories for unclaimed quarters (informal economy indicators, settlement indicators, economic stress indicators, faction presence indicators, plus two others). He asks Araminta what visual vocabulary distinguishes these categories. + +Araminta's empty quarter types (plaza, service alley, courtyard, vehicle staging, structural gap) overlap partially with Nigel's flavor categories but use different taxonomy. These two systems need reconciliation before chunk fill can be specified. + +**Raised by:** Nigel §7; Araminta §3.2. + +--- + +## Section 3: Dissent and Alternative Proposals + +### D-1: Ozzie's Skepticism About Second Station Syndrome + +Ozzie is not convinced the quarter system alone defeats structural recognizability. Her concern: "Even with quarter variation, if the blocks are always the same size and the streets are always aligned, I'll feel the skeleton underneath." She wants the generator to vary the SOCIAL AND POLITICAL skeleton, not just geometry within a fixed structural skeleton. + +This is not a rejection of the quarter system — it's a demand that the quarter system be a *consequence* of social/political variation, not a separate aesthetic variation pass. Her test: "two players should be able to compare notes and find genuinely different investigation experiences." + +This aligns with Nigel's Guarantee 1 (Structural Non-Repeatability Per Seed) but frames it from the player-experience side rather than the systems side. + +**Source:** Ozzie "The Generation Sins," "What Makes the 50th Station Exciting," "Quarter System" section. + +--- + +### D-2: Gestalt vs. Miri on Triangle Template Instantiation Stage + +**Gestalt's position:** "Triangle configuration is determined at the population stage... social graph → chunk fill. NOT: chunk fill → social graph. The generator must not produce spatial arrangements and then try to fill them with compatible social graphs. The social graph drives spatial requirements." + +**Miri's position:** Templates are instantiated "at the district skeleton stage, after zoning but before block generation," with "triangle assignments (who's in conflict with whom across templates)" as a skeleton field. + +These are not fully contradictory — Miri may be describing template *type* selection while Gestalt describes NPC *assignment* to triangle positions — but the vocabulary gap obscures whether they agree. Round 2 needs a shared definition of "triangle instantiation" before this can be resolved. + +**Sources:** Gestalt §5 Q-B; Miri §5 "when D-025 templates get instantiated." + +--- + +### D-3: Nigel's Reframing of the Generator's Purpose + +Nigel argues the generator's promise is not "300 worlds" but "300 × (characters) × (cultural combinations) × (seed entropy) distinct game experiences." He explicitly names structural randomness (who's entangled, where evidence is, which triangles are active) as more important than geometric variation. + +This reframes the generator's success criteria from "produces 300 visually distinct districts" to "produces distinct investigative experiences." The implication for architecture: entanglement assignment, triangle configuration, and evidence placement are first-class generator outputs — not emergent from spatial placement. + +No other participant dissents from this, but Araminta's contribution focuses entirely on spatial/visual coherence without addressing investigative experience variation. Whether these two framings are in tension will depend on how the pipeline's final stage ordering shakes out. + +**Source:** Nigel §1, §6, §7 summary table. + +--- + +## Section 4: Cross-Cutting Themes + +### Theme 1: Every Pipeline Stage Serves Gameplay + +Gestalt frames all generator requirements as "ultimately guarantees about asymmetric information production." Araminta frames all visual rules as serving "a player who can read where they are, what tier of access they're in, and where cover is." Nigel frames every variation axis by its "impact on player." Ozzie demands that every space "pay rent." + +The convergence: no pipeline stage is permitted to be purely aesthetic or purely technical. Infrastructure must produce surveillance topology AND stealth topology. Zoning must produce access tier palette. Block generation must produce chokepoints. Visual grammar must communicate spatial function. + +This is an implicit shared principle that Round 2 should make explicit, as it has implications for generator validation: the test of a generated district is not "does it look right" but "does it play right." + +--- + +### Theme 2: Dual-Reading Spaces + +Gestalt's G-08 principle ("every ring location reads as mundane; criminal function visible only to those who know") appears independently in two other voices: + +- Ozzie requires spaces that feel "like I wasn't meant to find it" — discovered, not guided-to. +- Miri requires grey economy spaces that "occupy the spaces official zoning doesn't account for." + +The generator must produce spaces that serve a manifest function (visible to all) and a latent function (visible to those with specific knowledge or access). This is not a post-generation content layer — it is a generation-time property. The template tag at chunk fill time must encode both functions. + +--- + +### Theme 3: Physical History as a Generator Input + +Multiple participants want the generator to produce spaces that feel like they have a history, not just a current state: + +- Miri's era-stratification (Era 1/2/3 as construction layers) is the primary mechanism. +- Miri's historical event modifier pass adds anomaly traces on top of steady-state generation. +- Nigel's historical event seed (§5 Layer 4) makes history leave physical traces — repurposed buildings, blocked corridors, NPCs with long-memory grievances. +- Ozzie demands "historical palimpsest" — layers built by different people with different plans. + +The common thread: history is not flavor text; it is a structural input that produces physical consequences. A district that experienced a corporate merger 10 years ago has two architectural styles and NPCs with residual loyalty conflicts. + +Whether the generator can produce this — and whether era tags alone are sufficient, or whether a dedicated historical event layer is required — is unresolved. + +--- + +### Theme 4: Minimum Viable District as v0.1 Deliverable + +Tyre and Gestalt both propose concrete v0.1 deliverables that validate the generator schema without running the generator: + +- Tyre: hand-author the Transit District as a `DistrictSkeleton` to validate schema expressiveness. Estimated ~3-4 developer-days. +- Gestalt: define the "minimum viable district" (1 workplace, 1 bar, 1 maintenance spine, 1 transit node, 1 restricted zone, 2 active triangles) as the completeness check. + +These should be treated as a single deliverable: a hand-authored DistrictSkeleton representing the Transit District, validated against Gestalt's seven spatial guarantees. If the schema can express all seven guarantees for the Transit District, it can express any generator output. + +--- + +## Section 5: Qatux Observations + +### Implicit Decision Emerging + +The following implicit decision appears to be forming across Round 1 and should be formally proposed in Round 2 for Jeroen's confirmation: + +**Proposed decision:** The district skeleton is the primary atomic output of the generator. It contains: social site slots (type + position), access topology, NPC capacity and pattern distribution per site, triangle assignments (NPC conflict topology), cultural modifier tags, multi-block reservations, corridor spine, access points, and zone palette assignments. The generator selects and arranges D-025 templates into skeletons; it does not modify templates. This is consistent with D-025 and Q-036. + +If confirmed, this should become a formal D-record. + +--- + +### Flag: Vocabulary Divergence on "Triangle Instantiation" + +Gestalt and Miri use overlapping vocabulary with potentially different meanings: + +| Term | Gestalt's meaning | Miri's meaning | +|------|-------------------|----------------| +| "Triangle instantiation" | NPC assignment to triangle positions (Population stage) | Selection of D-025 template types for social sites (Skeleton stage) | +| "Social graph drives spatial" | NPC conflict topology must precede spatial placement | Templates carry triangle connections as a skeleton field | + +This may be a complementary split (Miri = which template types go where; Gestalt = which NPCs fill which roles in those templates) rather than a contradiction. Round 2 should establish shared vocabulary. + +--- + +### Flag: Araminta and Nigel's Empty Quarter Taxonomies Need Reconciliation + +Araminta's empty quarter types: plaza, service alley, courtyard/garden, vehicle/cargo staging, structural gap (undeveloped). + +Nigel's flavor structure categories: informal economy indicators (market stalls, vendor carts, repair shops), settlement indicators (container gardens, seating clusters, shrines), economic stress indicators (shacks, abandoned equipment, unauthorized storage), faction presence indicators (Commission kiosks, union halls, corporate branded infrastructure). + +These two systems overlap but do not map cleanly to each other. Before chunk fill can be specified, a unified taxonomy of "what fills an unclaimed quarter" must be agreed. + +--- + +*Round 1 complete. Summary ready for Round 2 use. — Qatux* diff --git a/docs/workshops/generator-architecture/round-2-notes.md b/docs/workshops/generator-architecture/round-2-notes.md new file mode 100644 index 000000000..b78e59267 --- /dev/null +++ b/docs/workshops/generator-architecture/round-2-notes.md @@ -0,0 +1,493 @@ +# Generator Architecture Workshop — Round 2 Notes + +**Compiled by:** Qatux (Documenter) +**Date:** 2026-02-27 +**Source files:** +- `docs/workshops/generator-architecture/gestalt-round2.md` +- `docs/workshops/generator-architecture/tyre-round2.md` +- `docs/workshops/generator-architecture/miri-round2.md` +- `docs/workshops/generator-architecture/araminta-round2.md` +- `docs/workshops/generator-architecture/nigel-round2.md` +- `docs/workshops/generator-architecture/ozzie-round2.md` + +--- + +## Overview + +Round 2 was substantially reshaped by the lead directive: *this is NOT a detective game; it is a game about the inherent asymmetry of human awareness.* All six participants acknowledged and absorbed this fully. The dominant work of Round 2 was extending Round 1's investigation-centric architecture to serve tycoon, dating sim, political drama, and investigation playstyles simultaneously — plus opening the generator to non-urban terrain, insignificant places, and edge bleed. Eight of the eight Round 1 open questions were resolved. Four new open questions were raised. + +--- + +## Section 1: Resolved Questions from Round 1 + +### R-OQ-1: Pipeline Stage Ordering — Population Before or After Zoning? + +**Resolved.** Gestalt explicitly reversed his Round 1 position: + +> "I was wrong about needing full co-resolution of population and zoning." + +The solution, agreed by Tyre and Gestalt independently: + +**Two-pass process within Phase 1:** +- Pass 1 (district skeleton stage): Zone types → NPC role slot allocation → triangle topology selection → **spatial prerequisite validation** (verify required spaces exist for secrets that will be assigned; adjust zoning if not) +- Pass 2 (NPC population stage): 10-axis generation fills role slots with concrete NPCs; secrets anchored to already-confirmed spaces + +The validation pass (~200 lines of Rust, Tyre estimates 0.5 developer-days) catches edge cases where zoning fails to produce a required spatial type. The skeleton guarantees staging grounds before NPC generation runs. No feedback loop required. + +**Sources:** Gestalt §3 Stage 5 revised position; Tyre §6. + +--- + +### R-OQ-2: Seed Architecture — Single Master Seed or Per-Stage? + +**Resolved by lead directive.** Single master seed. All participants accept. + +Tyre provides the implementation: `SeedChain` with deterministic keyed-hash derivation: +``` +derive_seed(master_seed, domain_tag, index) → blake3(master || domain || index) +``` + +**Same seed + different character selection = same world.** Character selection is a filter (lens), not a world-generation input. The simulation produces the world; the character determines what the player can see and access within it. This fulfills D-027 ("two keyholes on the same world") and D-010 principle 3 (no baking player identity into the game loop). + +Nigel updates his variation axes table accordingly: character selection is a "lens" layer, not a generator axis. The world is identical; the perception differs. + +**Sources:** Tyre §3; Gestalt §4; Nigel §8. + +--- + +### R-OQ-3: Can the Quarter System Produce Social Variation, Not Just Visual? + +**Resolved.** Yes, through a `flavor type → NPC pattern weight modifier` mechanism. + +Gestalt, Nigel, and Tyre each describe this independently: + +| Flavor type | NPC pattern weight shift | +|---|---| +| Market stall cluster | +HANDLER (trade coordinator), +CIVILIAN (customers) | +| Commission kiosk | +SYSTEM (enforcement), −HANDLER | +| Container garden | +ANCHOR (community pillars), +NOBODY (background domestics) | +| Shack cluster | +CATALYST (people under pressure), +REMNANT (people left behind) | +| Union hall | +SYSTEM (organized labor), +WITNESS (institutional memory) | +| Corporate infrastructure | +SYSTEM (corporate agents), −ANCHOR | + +**Tyre's architectural note:** The quarter fill doesn't *cause* NPC behavior directly. Both the quarter fill and the NPC behavior are caused by the same upstream parameters (economic tier, cultural profile, faction presence). The player sees correlation and reads it as causation. That's architecturally correct — the relationship is real, just indirect. + +**Araminta's contribution:** The visual grammar for each flavor category communicates social reality, not investigation routes. Every player reads the same quarter through their own lens. The grammar serves all playstyles because it describes *who inhabits a space and on whose terms*. + +**Sources:** Gestalt §3 Chunk Fill stage; Tyre §8.1; Nigel §9; Araminta §6. + +--- + +### R-OQ-4: Can the Generator Produce Historical Palimpsest — Layers and Caused Irregularities? + +**Resolved.** Two complementary mechanisms: + +**1. `EraModification` system (Tyre):** `BlockSkeleton` carries `era: Era` + `era_modifications: Vec<EraModification>`. Each modification records era, coverage fraction, and `ModificationType` (`SurfaceRetrofit`, `InternalConversion`, `StructuralAddition`, `InstitutionalUpgrade`). Chunk fill reads the full modification history. + +**2. Cause fields (Gestalt):** Extends Tyre's system with `era_cause` — why a block's era differs from district norm (e.g., `corporate_merger`, `emergency_extension`, `organic_growth`). The `ChunkLayout::LShape` variant carries an analogous cause field. These causes manifest as visual evidence in chunk fill: a material seam for `organic_growth`, different era materials on the addition in `acquisition_boundary`. + +**Araminta's Technique 4 (infrastructure routing):** A power conduit or rail line running at a slight angle to the street grid reads as older than the layout it crosses — "historical palimpsest" as diagonal infrastructure. + +The generator records history as a sequence of modifications, not just a current state. The player may not consciously articulate the reason, but they feel "this shape makes sense here" (Ozzie's requirement for CAUSED rather than RANDOM irregularities). + +**Sources:** Tyre §5 era fields; Gestalt §7 era_cause addition; Araminta §3 Technique 4; Miri §2 historical events. + +--- + +### R-OQ-5: Society Profile YAML as Serde-Compatible Schema? + +**Resolved.** Tyre provides the complete Rust struct with serde derive macros. Estimate: ~1 developer-day for all enum types and validation. + +Key points: +- NULL values serialize as `Option<T>` with serde default +- Heritage blend weights serialize as `Vec<HeritageEntry>`, validated to sum ≈ 1.0 (±0.01 tolerance) +- Society profiles can be hand-authored in YAML (for specific systems like Krenn), generator-derived from seed, or loaded via `serde_yaml` +- A `validate()` method checks contradictory faction presence, weight sums, and count constraints + +**Sources:** Tyre §4. + +--- + +### R-OQ-6: Era Stratification in Chunk Data Structure? + +**Resolved.** Era is assigned at **block level** (in Phase 1, Block Planning stage) and inherited by all chunks within the block. + +`BlockSkeleton` gains: +- `era: Era` — base construction era (Era1 / Era2 / Era3) +- `era_modifications: Vec<EraModification>` — retrofits and additions + +The z-level correlation from D-093 (z=0 → Era 1, z=1 → Era 2, z=2 → Era 3) is a *default pattern*, not mandatory. Per-block generation can deviate (a recently rebuilt ground level could be Era 3; an old observation deck could be Era 1). + +**Araminta's confirmation:** Era tags are assigned at block generation time, not chunk fill time. Adjacent blocks can have different eras; the visual transition happens at block boundary chunks via setbacks, service alleys, or material seams. + +**Sources:** Tyre §5; Araminta Round 1 §1.3 (confirmed unchanged). + +--- + +### R-OQ-7: Size of the Cultural Ingredients Space? + +**Resolved by Miri.** The raw combination space is hundreds of thousands; the gameplay-distinguishable space is **~1,000–7,700+ compositions**. At 300 worlds, the game samples a small fraction of available variety. + +Correcting Nigel's Round 1 estimate of "20 distinct compositions": the actual space is ~100 minimum. Updated calculation: **300 worlds × 2 characters × 100 minimum cultural compositions = 60,000 meaningfully distinct games** before seed entropy. + +**Critical caveat (Miri):** The binding constraint is **template library depth**, not the ingredients space. Cultural variety without template variety means cultural feel changes but spatial feel repeats. Template library expansion is the correct lever for expanding perceived variety. + +**Nigel's response:** Sufficient for cross-world variety. Insufficient for within-world replayability — which comes from seed-driven NPC generation, triangle configuration, and entanglement assignment that vary *within* cultural parameters. The cultural composition is the setting; it's stable. The seed variation is the gameplay. + +**Nigel's addition:** Economic pressure combination is the highest-resolution variation lever for *player-perceived* variety because it changes the emotional texture of the world, not just its mechanics. Two transit hubs with different economic pressure combinations feel like different kinds of humanity. + +**Sources:** Miri §6; Nigel §7. + +--- + +### R-OQ-8: Empty Quarter Taxonomy Reconciliation? + +**Fully resolved.** The two taxonomies operate at different abstraction levels and compose cleanly. + +**Unified two-layer model (Araminta, confirmed by Tyre and Nigel):** + +Every empty/unclaimed quarter gets: +1. **Spatial form** (Araminta's 5 types): Open plaza, Service alley, Courtyard, Staging ground, Undeveloped gap — answers "what SHAPE is this space and what are its visual/access properties?" +2. **Content category** (Nigel's 4 + Civic baseline): Civic baseline, Informal economy, Settlement, Economic stress, Faction presence — answers "what CONTENT occupies this space and what does it communicate about social/economic state?" + +Araminta provides a full compatibility matrix (25 combinations, with valid/invalid markings). Tyre formalizes as a `QuarterFill` struct with `form: QuarterForm` and `function: QuarterFunction`. The generator maintains a form×function validity table. + +**Visual vocabulary for each content category (Araminta §5):** +- Informal economy: warm irregular lighting, non-aligned awning structures on z=4, goods-display floor patterns, vendor-specific warm light pools +- Settlement: organic overhead elements (container gardens on z=4), non-matching furniture, personal shrines, warmer ambient than zone baseline +- Economic stress: failed/missing fixtures, damaged floor tile variants, abandoned equipment in irregular positions +- Faction presence: cold standardized objects, institutional signage, uniform maintained lighting + +**Sources:** Tyre §8.3; Araminta §4; Nigel §6. + +--- + +## Section 2: Consensus Points Emerging in Round 2 + +### C-R2-1: Two-Phase Generation Architecture + +Universal acceptance. Tyre provides the concrete implementation; Gestalt endorses and extends it; all other participants work within it. + +**Phase 1 (Background Prep, async, ~50–500ms per district):** +1. System Generation (star type, worlds, stations) +2. Society Profile per world (serde YAML → Rust struct) +3. District Skeleton per world (zoning, social sites, access topology, NPC slots, reservations, corridor spines, zone palettes, boundary descriptors) +4. Block Planning per district (ChunkLayouts, edge contracts, era tags, quarter pre-assignments, landmark slots) +5. NPC Population per district (role slot filling, triangle configuration, entanglement marking, spawn location preferences) +6. Transition Strip Generation per shared edge (palette blending, access point alignment) + +Output: `PreparedDistrict` struct (~10–50 KB per district; all 300-world galaxy fits in ~30–150 MB) + +**Phase 2 (Local Area Gen, on-demand, ~100–500ms per chunk):** +- Chunk Fill as player enters loading radius (template stamping, zone palette, era materials, NPC spawn points, LOS anchors) +- Output: `ChunkData` cached, saved, never regenerated + +The `PreparedDistrict` is the formal contract between phases. Phase 2 never calls Phase 1 functions; Phase 1 never produces tile data. + +**Scheduling:** Home system Phase 1 is blocking at game start (~2–3s). Neighboring systems queue by gate distance. On-demand preparation when player books travel. + +**Sources:** Tyre §1; Gestalt §3. + +--- + +### C-R2-2: Edge Bleed Solution (Technical) + +Tyre and Araminta converge on complementary solutions. + +**Tyre's structural approach:** The outermost column/row of each district is a *transition strip*. `DistrictSkeleton` gains a `boundaries: DistrictBoundaries` field describing what each edge offers to the shared transition zone. Transition blocks: +- Blend zone palettes (weighted average of both adjacent zones) +- Use older of the two boundary eras +- Carry no social sites (pass-through zones only) +- Have smaller building footprints (no full-merge buildings) +- Connect access points from both districts, dead-ending gracefully where only one district offers a corridor + +Memory cost: ~4 KB per shared edge; trivial. + +**Araminta's visual rules for transitional blocks:** +- Floor tiles interpolate over the 64vt block width +- Wall materials do NOT interpolate (structural integrity reads; inconsistent walls read as construction error) +- Lighting fixture temperature interpolates +- Ambient (CanvasModulate) interpolates +- Overhead elements follow the building's home district palette — no interpolation + +**Test (Araminta):** A player who has stopped moving in a boundary zone should not be able to say with certainty "I'm in District A" vs. "I'm in District B." They should feel "somewhere between institutional and residential." + +**Miri's cultural bleed distinction:** Two types of bleed behave differently: +- **Faction bleed**: radius-geometric from faction infrastructure, decays by block distance — predictable, detective can map it +- **Cultural bleed**: flow-path along NPC movement corridors, strongest along high-traffic routes — requires knowing how people actually move + +Shared boundary social sites serve both adjacent district cultures and are the primary sources of cross-triangle triangles (D-024). + +**Sources:** Tyre §2; Araminta §1; Miri §5. + +--- + +### C-R2-3: Playstyle-Agnostic Spatial Archetypes + +Gestalt's revised guarantee set, accepted without challenge by all participants: + +**7 universal spatial archetypes (every Full-complexity district must contain at least 1 of each):** + +| Archetype | Investigation use | Tycoon use | Dating sim use | Political use | +|---|---|---|---|---| +| Traffic Chokepoint | Observation point | Trade route leverage | Serendipitous encounter | Campaign territory | +| Informal Zone | Quiet zone / dead drops | Grey market space | Privacy / trysts | Back-channel meetings | +| Social Hub | Rapport-building | Networking | Romance venue | Influence gathering | +| Institutional Space | Authority access | Licensing/permits | Formal encounter | Power center | +| Insider Space | Ring access visible | Guild/cooperative | Close friend group | Party/faction HQ | +| Economic Node | Evidence trail (money follows crime) | Primary profit opportunity | Shared activity | Leverage over economic actors | +| Encounter Corridor | NPC observation route | Supply chain link | Daily routine overlap | Visibility territory | + +**4 additional per-playstyle guarantees:** +- Tycoon: ≥1 economic asymmetry signal (demand gap, price differential, prohibited supply) +- Dating sim / social: ≥1 temporal encounter window (social hub with defined active day-phases, D-031 integration) +- Political: ≥1 power gradient visibility (SYSTEM-pattern NPC in visible authority position) +- Non-urban only: natural chokepoint replacing the architectural corridor (mountain pass, harbor mouth, river ford) + +**11-check guarantee audit** (Gestalt proposes runtime validation — all 11 checks serialized into the `DistrictSkeleton` as `guarantee_audit: GuaranteeAuditResult`). + +**Ozzie's evaluation:** She asked Gestalt to reframe investigation-vocabulary guarantees for all playstyles. She does not directly endorse or reject the revised formulation in Round 2 — will assess in Round 3. + +**Sources:** Gestalt §1–2 and §9 MVD table. + +--- + +### C-R2-4: Non-Urban Terrain in Same Pipeline + +Universal acceptance: same 4-level hierarchy, same pipeline stages, same architectural abstractions — different input parameters, different template libraries. + +**What changes for non-urban:** +- Fill density: urban 60–100% quarters filled → non-urban 0–20% (wilderness) to 20–40% (agricultural) +- Template types: building templates → terrain templates (fields, forest, water, paths) +- NPC density: 30–80 per urban district → 0–10 for wilderness +- Edge contracts: door/corridor connections → path/road connections +- LOS anchors: walls, pillars, furniture → trees, rock formations, fences, elevation changes +- Zone palette: architectural materials → natural materials +- Lighting model: PointLight2D fixture pools → global ambient (CanvasModulate) + canopy overhead layer as urban-equivalent occlusion + +**Araminta's natural zone palettes:** Five new palettes defined: farmland (dark warm brown soil, amber sparse nocturnal), wilderness/forest (near-black floor, dense canopy overhead as urban-wall equivalent), ocean/coastal (near-black deep blue, animated specular reflection), beach (dark warm tan, global ambient only), mountain/snow (dark cold stone + bright snow inversion — only terrain where floor is lighter than ambient), secluded town (warm brown-grey, personal accumulated overhead elements as cultural expression). + +**Tyre's `TerrainType` enum:** Station, Urban, Agricultural, Wilderness(biome), Water(water_type), Transitional, Orbital. + +**Tyre's `ComplexityTier` enum:** Full, Moderate, Minimal, Empty. **Gameplay guarantees apply to Full complexity only.** A farmland district doesn't need a surveillance chokepoint. + +**Sources:** Tyre §7; Miri §3; Araminta §2; Nigel §3; Gestalt §5. + +--- + +### C-R2-5: Insignificant Places as a First-Class State + +All participants accept. Miri's framing is the most precise: + +> Insignificance is not a property of the society profile. It's a relation — a place is insignificant RELATIVE to the wider network. + +**Miri's "insignificant" society profile characteristics:** High drift novelty (no cosmopolitan dilution), high insider trust threshold (a stranger is a social event), low information density, inverted anonymity (the player *cannot* be anonymous — everyone learns their name within hours). **The information asymmetry challenge inverts**: not "discover what's hidden" but "manage that you can't hide anything." + +**Nigel's "drama density" axis:** Zero (no Tier 1 modules, stable social fabric, guaranteed quiet) → Low → Medium → High → Flashpoint (rare, must feel rare). The storyteller uses drama density as a pacing lever. A backwater is not low-content — it's a *promise* that genuine quiet is available. + +**Nigel's "false backwater" concept:** A world that APPEARS to be a backwater but is a critical logistical node for a cross-system ring. The investigation player who investigates finds this. The tycoon player who passes through without looking finds nothing. Same generator output. Different game. + +**Ozzie's requirement for backwaters:** They must be "complete, not failed hubs." Small, dense in human entanglement, strongly expressed cultural ingredients, history recent enough to be personally remembered. The player's arrival is an event. The generation win is density of human detail in a small space. + +**Sources:** Miri §4; Nigel §2; Gestalt Stage 0; Tyre §7.5; Ozzie "Backwaters" section. + +--- + +### C-R2-6: Society Profile as Playstyle-Agnostic Information Structure + +Miri's central contribution: the society profile already contains what all playstyles need. The gap is not the profile but **what information categories are tracked** and **what actions they unlock**. + +**By playstyle:** +- Investigation: evidence of hidden activities → confrontation/exposure +- Tycoon: economic intelligence (trade flows, price differentials, information barriers) → trade advantages, economic leverage +- Dating sim: social/personal knowledge (relationship formation norms, trust mechanism) → relationship phases, access to private spaces +- Political drama: power intelligence (faction relationships, leverage map, destabilizing secrets) → alliance formation, position seizure, scandal detonation + +**Key insight (Miri):** The political drama and investigation crossover is structural. Investigation finds truth; political drama finds leverage. The knowledge graph (D-041) serves both. The difference is what the player chooses to DO with `KnowsDetails`-tier information. + +**Dating sim and triangle structure:** Romantic competition is structurally identical to the investigation triangle (three NPCs with conflicting interests). The generator's D-024 model handles dating sim mechanics without modification. What changes is the *content tags* on triangle nodes (`motivation: romantic-rival` vs `motivation: operator`). + +**Miri's addition:** Tourist economy settings require dual NPC population profiles — resident workers (reserved, labor-solidarity) and visitor tourists (open, friendly, with a countdown departure date). The class contrast is explicit and spatial. Three-zone access structure maps cleanly onto Gestalt's access tier model. + +**Sources:** Miri §2; Nigel §1. + +--- + +### C-R2-7: DLC as Template Library Expansion Model + +Introduced by Miri, endorsed by Nigel. The ingredients menu stays stable; DLC adds eligible templates per ingredient combination. The generator gracefully falls back to base game templates if a DLC template is selected but unavailable. + +Proposed DLC structure: +- Base game: logistics, residential, administrative, bar/social, maintenance, gate cluster +- "Agricultural Worlds" DLC: farmstead, granary, rural tavern, market day, seasonal camp, mill complex +- "Maritime Settlements" DLC: fishing dock, harbor bar, vessel interior, lighthouse, chandlery +- "Leisure Economies" DLC: resort lodge, surf shack, mountain chalet, seasonal service housing + +**Sources:** Miri §6.3; Nigel §3.4. + +--- + +## Section 3: New Open Questions for Round 3 + +### OQ-R3-A: Can the Block Grid Rotate or Breathe? + +**Raised by Ozzie. Critical. Not addressed by any other participant.** + +Ozzie's concern: even with quarter variation, L-shapes, and edge bleed, the underlying 4×4 block grid with perpendicular streets remains perceptible over multiple playthroughs. She explicitly asks: + +> "Can two adjacent districts have different orientations? Can streets curve? Can blocks be non-rectilinear?" + +Araminta's seven anti-grid visual techniques (diagonal connectors, irregular setbacks, overhead extension past block edges, angled infrastructure, light territories, vegetation overflow, street width variation) partially address this — but they hide the grid through visual means rather than removing it architecturally. + +This may require an explicit decision: either (a) the grid breathes at the district generation stage (infrastructure stage can rotate blocks or introduce non-right-angle arrangements) or (b) the grid remains fixed and visual techniques are the full mitigation strategy. If (b), the team should evaluate whether that's sufficient against Ozzie's stated concern. + +**Stakes:** If Ozzie is right, Second Station Syndrome re-emerges at the structural level even after all other problems are solved. + +**For Round 3:** Tyre to address whether the D-094 hierarchy can accommodate non-rectilinear block arrangements, or whether this is deferred to a later milestone. + +--- + +### OQ-R3-B: Triangle Purpose Taxonomy + +**Raised by Gestalt.** The proposed `triangle_purpose: TrianglePurpose` field (investigation/economic/political/social) on `SocialSitePlacement.triangles` needs formal definition. + +**Stakes:** If triangles carry purpose tags, the scenario instantiation stage can activate relevant triangles based on active playstyle context. Without this, all triangles activate regardless of relevance. This is the mechanism that makes the political drama's "active triangles" differ from the investigation's "active triangles" in the same district. + +**For Round 3:** Tyre to confirm whether `TrianglePurpose` adds meaningful implementation complexity, or whether it's a simple tag on the existing `TriangleTemplate` struct. + +--- + +### OQ-R3-C: Maritime/Wilderness Informal Zone + +**Raised by Gestalt.** The Informal Zone archetype requires deliberate generation in every Full-complexity district. For architectural settings, this is a maintenance corridor or service back-alley. For wilderness/maritime settings, there is no equivalent. + +Gestalt proposes `terrain_informal_zone` as a geography-defined sheltered space (cave, ravine, hidden cove). This satisfies the same gameplay guarantee (degraded institutional coverage, low ambient traffic, suitable for private or unofficial activity) through terrain rather than infrastructure. + +**For Round 3:** Miri to confirm what wilderness informal zones look like culturally (what does "private exchange" mean when there is no institutional authority to hide from?). + +--- + +### OQ-R3-D: Vessel Architecture — Entity-Carried Chunks + +**Raised by Miri.** The `bounded_mobile` social site tag for vessels (ships, boats) may require an entity-carried chunk — a chunk that moves rather than being fixed to a coordinate. This is potentially architecturally significant. + +> "Vessels move — this might require an entity-carried chunk, which is architecturally complex." + +**For Round 3:** Tyre to assess whether mobile chunks are within the D-012 streaming model's scope, require a separate mechanism, or should be deferred to a later milestone. + +--- + +### OQ-R3-E: The Horizon as a Generator Landmark + +**Raised by Ozzie.** Walking to the edge of a coastal settlement and seeing ocean for the first time should be a Wow Moment. The generator must treat water's edge as a landmark equivalent — not a blank space. + +This is partially addressed by Araminta's ocean zone palette (open water has dramatically extended LOS; shore is a transitional band). But the *generator* must also treat the coastal edge as a reserved landmark slot, analogous to Araminta's district quadrant landmark rule (1 per quadrant). + +**For Round 3:** Araminta to confirm whether "water's edge as automatic landmark" is handled by the natural zone palette visual grammar, or requires an explicit landmark reservation at the district skeleton stage. + +--- + +## Section 4: Dissent and Tensions + +### D-R2-1: Ozzie's Partial Dissent on Anti-Grid + +Ozzie explicitly says the current architecture "partially addresses" her grid concern — not fully. Her seven techniques are visual camouflage; they don't change the underlying architecture. She is making this a Round 3 demand: + +> "Tell me the grid can breathe. Tell me two adjacent districts can have different orientations. Tell me a street can curve because the geography required it." + +This is the one significant tension where a participant is unsatisfied with the Round 2 response. The team lead or Tyre must address this directly. + +--- + +### D-R2-2: Miri on Social Site Template Diversity + +Miri notes the investigation-centric design produced one primary social site type (the bar — shift-end social aggregation). Dating sim gameplay requires more types: communal meal space, recreational gathering venue, domestic invitation threshold. These are different D-025 templates, not a pipeline change. + +Miri proposes the DLC model as the solution: base game templates serve investigation/tycoon; social expansion pack adds dating sim template library. This is a reasonable position but defers the dating sim's template requirements to a later milestone. No other participant challenged this, but it should be noted as a scoping decision. + +--- + +### D-R2-3: Gestalt Adds Fields to Tyre's Data Structures Without Cross-Reference + +Gestalt proposes three additions to `DistrictSkeleton`: +1. `significance_tier: SignificanceTier` +2. `setting_geometry: SettingGeometry` +3. `guarantee_audit: GuaranteeAuditResult` + +And a modification to `SocialSitePlacement.triangles`: add `triangle_purpose: TrianglePurpose`. + +Tyre's Round 2 also adds three fields to `DistrictSkeleton` (`society_profile`, `terrain`, `complexity`), plus the full `DistrictBoundaries` struct. + +These are complementary, not contradictory — but the combined `DistrictSkeleton` struct needs to be reconciled as a single canonical definition. Neither Tyre nor Gestalt was working from a shared draft. Round 3 should produce a unified struct definition. + +--- + +## Section 5: Cross-Cutting Themes + +### Theme 1: The Information Landscape Is Universal; The Lens Varies + +Miri's framing provides the unifying theory: the generator produces one information landscape; what varies is which information the player's archetype seeks and what they do with it. Investigation reads the landscape as a crime scene. Tycoon reads it as a market. Dating sim reads it as a social web. Political drama reads it as a power structure. + +This reframes the generator's success criterion: not "does it produce 300 visually distinct districts" but "does it produce 300 districts with rich enough information landscapes that all four playstyles find distinct, valid experiences within each one." + +--- + +### Theme 2: Economic Pressure Combination Is the Highest-Leverage Variation Lever + +Both Miri and Nigel independently converge on this. Miri demonstrates it through the Sova vs. Station Vareth contrast. Nigel elevates it as the variable that most changes *emotional texture* rather than just mechanical parameters. + +Two Transit Hub districts with different economic pressure combinations feel like different kinds of humanity — the grey economy on a `[tight-margin, prohibition-economy]` world is rational and ideologically defensible; on a `[survival-gap, prohibition-economy]` world it is desperate and morally fraught. The investigation, the tycoon opportunity, the romantic stakes, and the political fault lines all change. + +--- + +### Theme 3: Contrast as Content + +Multiple participants name the spectrum from backwater to epicenter as itself a content dimension: +- Nigel: drama density axis, backwaters as pacing tools for the storyteller +- Ozzie: backwaters require the player's arrival to be an event; they are complete worlds at small scale +- Miri: insignificant places have inverted information dynamics — high visibility, intimate conspiracy, no anonymity + +The implication: the generator must not treat low-drama districts as scale-reduced versions of high-drama districts. They are categorically different content types with their own generator requirements. + +--- + +### Theme 4: Template Library Depth as the Binding Constraint + +Named explicitly by Miri, implied by Ozzie (who needs non-urban visual vocabulary), and addressed by the DLC model. The generator architecture is sound. The generator's variety ceiling is the D-025 template library, not the pipeline design. + +This suggests the roadmap emphasis: once the generator pipeline is validated (Tyre's v0.1–v0.3 estimate), the primary work driving player-perceived variety shifts to template library authoring and expansion. + +--- + +## Section 6: Qatux Observations + +### Implicit Decision Forming: DistrictSkeleton Canonicalization + +Tyre and Gestalt are both adding fields to `DistrictSkeleton` without a shared draft. The current composite of their proposals would include: +- Tyre's original fields (district_id, seed, district_type, context, blocks, social_sites, reservations, access_points, corridors, z_levels, zone_palette) +- Tyre's R2 additions: boundaries, society_profile, terrain, complexity +- Gestalt's R2 additions: significance_tier, setting_geometry, guarantee_audit +- Gestalt's R2 modification: `triangle_purpose` on `SocialSitePlacement.triangles` +- Gestalt's era_cause on `BlockSkeleton` + +**For Round 3:** A canonical `DistrictSkeleton` struct definition should be produced that reconciles all additions. Tyre is the appropriate author given the technical ownership. + +--- + +### Flag: "Drama Density" and "Significance Tier" Are Overlapping Concepts + +Gestalt's Stage 0 produces `SignificanceTier` (Center-stage / Regional / Backwater / Waypoint / Insignificant). +Tyre's `ComplexityTier` produces (Full / Moderate / Minimal / Empty). +Nigel's "Drama Density" axis produces (Zero → Low → Medium → High → Flashpoint). + +These three concepts describe the same underlying parameter with different vocabulary and granularity. They need reconciliation before the Pre-Pipeline stage can be formally specified. Likely these collapse to one parameter (or two: a static complexity/significance tier and a dynamic drama density that the storyteller can modify). + +--- + +### Flag: Vessel/Maritime Architecture Needs Decision Before Template Authoring Begins + +If maritime DLC templates include vessel interiors as `bounded_mobile` social sites, and if vessel interiors require mobile chunks, this architectural decision needs to be made before maritime template authoring begins. Authoring vessel interiors for a static chunk architecture would waste work if mobile chunks turn out to be required. + +--- + +*Round 2 complete. All 8 Round 1 open questions resolved. 5 new questions raised for Round 3. — Qatux* diff --git a/docs/workshops/generator-architecture/round-3-notes.md b/docs/workshops/generator-architecture/round-3-notes.md new file mode 100644 index 000000000..d688897a0 --- /dev/null +++ b/docs/workshops/generator-architecture/round-3-notes.md @@ -0,0 +1,551 @@ +# Generator Architecture Workshop — Round 3 Notes + +**Compiled by:** Qatux (Documenter) +**Date:** 2026-02-27 +**Source files:** +- `docs/workshops/generator-architecture/gestalt-round3.md` +- `docs/workshops/generator-architecture/tyre-round3.md` +- `docs/workshops/generator-architecture/miri-round3.md` +- `docs/workshops/generator-architecture/miri-round3-supplement.md` +- `docs/workshops/generator-architecture/araminta-round3.md` +- `docs/workshops/generator-architecture/nigel-round3.md` +- `docs/workshops/generator-architecture/ozzie-round3.md` + +--- + +## Overview + +Round 3 is the convergence round. Nine lead directives were addressed. Five Round 2 open questions were resolved. The canonical DistrictSkeleton Rust struct was produced by Tyre. The pipeline architecture was formally stated by Gestalt. The generator is now structurally sound through v0.3, with v0.4 features (skyscrapers, full mobile chunks) scaffolded but deferred. + +One structural tension carries into Round 4: vessel architecture (Tyre's entity-carried MobileChunk vs. Nigel's instanced district). This must be resolved before maritime and transit templates are authored. + +--- + +## Section 1: Round 2 Open Questions — Resolution Status + +### OQ-R3-A: Block Grid Rotation / "Breathing" + +**Resolved.** Tyre delivers the structural answer Ozzie demanded. + +D-094 defines **data sizes**, not geometry. A `DistrictLayoutMode` enum governs block placement: + +```rust +enum DistrictLayoutMode { + Grid, + Organic { placements: [[BlockPlacement; 4]; 4] }, +} +struct BlockPlacement { + offset: (i16, i16), // ±16 sim tiles per axis + rotation_steps: u8, // 0–3 in 15° increments (max 45°) + street_width_factor: f32, // 0.75–2.0 +} +``` + +In `Organic` mode: streets are the **negative space** between shifted/rotated blocks. Adjacent districts can have different layout modes. The transition strip handles orientation mismatch. The 45° cap is hard (beyond that, tile-based pathfinding breaks). + +Araminta provides the visual grammar for organic districts: 45° and angled wall tile variants, increased landmark density (12 vt vs 16 vt), wider street width range (4–14 vt), face-defined blocks. `grid_orientation: f32` on `DistrictSkeleton` propagates rotation to chunk fill. + +Miri provides the generative logic: grid = power imposed (Commission/Syndic planning, Arc heritage, Era 3 development). Organic = power negotiated (Iron/Dust/Tide settlement, Era 1 pioneer foundations, maintenance/residential zones). Within a district, blocks can have different `street_geometry` assignments based on era and heritage. + +Ozzie confirms: Araminta's seven anti-grid visual techniques are **camouflage, not structure**. Tyre's organic mode provides the structural answer. Ozzie accepts this as satisfying her demand. + +**Outstanding note:** Tyre's cap is 45° rotation steps. For truly curving streets (Ozzie's "streets that curve because terrain required it"), the visual impression of curve comes from 45° jogs at intervals. True smooth curves are not achievable in the tile engine at v0.1–v0.3. Angular organic layouts, not flowing curves. + +--- + +### OQ-R3-B: Triangle Purpose Taxonomy + +**Resolved.** Gestalt proposes the enum; Tyre confirms it as implementation-trivial. + +```rust +enum TrianglePurpose { + Investigation, Economic, Social, Political, Tactical, Mundane, +} +``` + +`Tactical` is the new addition for Round 3: the triangle whose three nodes are the target, their protector/guardian, and an informant or witness. Any NPC who is a potential assassination contract target is the central node of a `Tactical` triangle. + +Triangles carry `purposes: Vec<TrianglePurpose>` — a triangle can serve multiple purposes. Mundane triangles (workplace rivalry, neighbour disputes) are always present in inhabited districts. The scenario instantiation system activates triangles based on which purposes are relevant to the player's current engagement. + +Implementation: one `Vec<TrianglePurpose>` field on `TriangleAssignment`. ~20 lines of code. + +--- + +### OQ-R3-C: Maritime / Wilderness Informal Zone + +**Resolved.** Miri redefines the informal zone for non-institutional settings. + +In institutionally-governed urban settings, the informal zone is defined by **institutional absence** (outside Meridian coverage). In non-institutional settings, the informal zone must be redefined as **outside the community social field**. + +Three informal zone types for non-urban settings: +- `social_permission` — convention covers this space (what happens on the boat is the crew's business) +- `physical_distance` — community observation doesn't reach here without intent (the far fields in off-season) +- `utilitarian_cover` — normal function provides plausible presence (in the barn checking the animals) + +Heritage root determines which type appears: Frost → `physical_distance`; Tide/Dust → `social_permission`; Iron → `utilitarian_cover`. + +Wilderness is the extreme case: the whole terrain is low-coverage, no Meridian equivalent. Nigel confirms: "the wilderness itself is the informal zone." The generator satisfies the Tier 1 Informal Zone guarantee for wilderness settings automatically — the biome flag counts. + +Generator tag: `terrain_informal_zone` (Gestalt's term), required per Full-complexity non-urban district. + +--- + +### OQ-R3-D: Vessel Architecture + +**Partially resolved — architectural split requires Round 4 decision.** + +Two structurally different proposals were submitted: + +**Tyre (entity-carried MobileChunk):** +```rust +struct MobileChunk { + entity_id: EntityId, + data: ChunkData, + interior_size: (u16, u16), + world_position: WorldPosition, + movement: MobileMovementState, // Docked | InTransit | InterSystem + access_points: Vec<MobileAccessPoint>, +} +``` +The chunk is a real world entity. In `InTransit`, the exterior is a scrolling visual buffer (not loaded terrain tiles). In `InterSystem`, only the interior exists. + +**Nigel (instanced district):** +The vessel is a district instance generated at journey-start, loaded as normal district data, terminated at arrival. Visual movement is client-side animation (parallax background on window tiles). No coordinate system complexity; full D-094 compatibility. Tiles don't move. + +These are **fundamentally different architectures** with different simulation implications. Tyre's model enables vessels to exist as world entities between voyages (docked at port, visible on the map). Nigel's model is cheaper and simpler but vessels only exist during voyages. + +Both agree on: vessel interiors use the same NPC simulation system; passenger manifests are seeded at journey-start; temporal pressure (arrival deadline) is core gameplay. + +Miri supplement provides cultural grammar for both models regardless of architecture: trains as `BoundedLinear` setting type with car-sequence social grammar; spaceships as institutionally-suspended environments; `transit_social_modifier` that adjusts trust-building rate, privacy level, and information flow. + +**For the record:** this decision must be made before maritime and vessel template authoring begins. It affects the streaming model, the world map, and the NPC simulation tick. + +--- + +### OQ-R3-E: Horizon as Generator Landmark + +**Resolved.** Araminta delivers the mandatory reservation rule. + +> **Rule:** Coastal districts must include a "horizon view" landmark reservation in the DistrictSkeleton — a mandatory negative-space view corridor of minimum 8 visual tiles unobstructed from street to water. + +This reservation prevents the generator from placing a warehouse at the water's edge. The view must exist. The palette guarantees the *feeling*; the reservation guarantees the *moment*. + +Gestalt confirms: the horizon view corridor is a Tier 2 guarantee for any district with `TerrainType::Water` on one boundary. + +Ozzie confirms: the horizon is not negotiable. It is one of the game's primary Wow Moments. + +--- + +## Section 2: Directive Coverage — All Nine + +### Directive 1: Assassin Lens + +**Fully addressed.** Team consensus: the assassin reads existing spaces differently, not new spaces. + +**Gestalt** extends the 7 spatial archetypes with an assassin column (see below) and adds 4 assassin-specific spatial guarantees: +- **A-1 Elevated Vantage:** Full-complexity district must have ≥1 position at z=1+ with LOS cone covering the primary Traffic Chokepoint +- **A-2 Egress Multiplicity:** Every district entry point has ≥2 independent egress routes with no shared secondary chokepoint +- **A-3 Temporal Opacity Window:** Full-complexity districts have ≥1 day-phase period where Traffic Chokepoint observer density drops below crowd-cover threshold +- **A-4 Non-Institutional Access Route:** At least one path from district entry to Social Hub not crossing an access tier above `Semi-Private` (this serves all playstyles) + +These are derived properties from existing spatial configuration, not new spaces. The guarantee audit checks whether the spatial configuration satisfies them. + +**Miri** adds cultural depth: observational density × information liquidity × aftermath engagement = `assassination_difficulty: low/medium/high/extreme` (derived from society profile, not a new field). Heritage root table provides specific operational implications: +- Frost: low observation, low liquidity, rigid pattern, low aftermath — paradise for operators, intelligence nightmare +- Dust: maximum observation, high liquidity, high aftermath — community is both intelligence source and witness +- Arc: low social surveillance during operation, INSTITUTIONAL RIGOR investigation afterward — the most dangerous aftermath + +**Nigel** adds replayability dimension: sightline geometry varies per seed (quarter merge patterns), crowd patterns vary (seeded schedules + entanglement), escape topology varies (block backside quarter structure), timing windows vary (NPC routine seeding). Hard requirement flagged: archetype placement must vary in **angular position** per seed, not just distance from center. Otherwise experienced players overlay a mental template. + +**Ozzie** names 4 assassination generation sins: +1. Omnidirectional witness coverage (must produce blind spots) +2. Escape routes that all converge (must produce multiple independent exits) +3. No vertical option (must guarantee ≥1 elevated access per district) +4. No crowd rhythm (NPC density must vary by day-phase) + +--- + +### Directive 2: Destructible Boundaries + +**Fully addressed.** Multi-layer solution across architecture, worldbuilding, and visual grammar. + +**Gestalt** establishes the generator contract: +- All tile data pre-exists (no on-the-fly generation on breach) +- `TileBehindState` for every wall: `StructuralFill | HiddenRoom | Interstitial` +- `AccessTier::BreachOnly` as a first-class access tier +- **Guarantee:** Every Full-complexity district must contain ≥1 zone classified `BreachOnly` — a space with no non-destructive access route + +**Tyre** provides the implementation: +```rust +enum WallBackside { + AdjacentSpace, StructuralFill, ServiceVoid, ChunkBoundary, Exterior, +} +``` +No tile is ever "void" (ungenerated). 90% of wall tagging is automated; 10% is author choice. `ServiceVoid` (1-3 tiles of pipe/conduit) is visually interesting and supports modified LOS/object-passing. + +**Miri supplement** provides cultural/worldbuilding layer: +```yaml +behind_boundary: + content_type: private_domestic | authority_operational | economic_storage | + structural_original | abandoned_era | active_concealment + era: era1 | era2 | era3 + cultural_sensitivity: low | medium | high | extreme + contents_hint: null | infrastructure | records | inventory | persons | evidence + breach_consequence: + immediate: null | alarm | NPC_response | environmental_hazard + social: none | community_sanction | faction_response | vendetta_trigger +``` +Era 3 wall in active use with no official record = someone sealed something recently and deliberately. + +**Araminta** provides 3 visual cases: adjacent room (ragged edge, revealed floor, narrowed LOS cone), infrastructure cavity (color-coded conduits at z=1.5: power `#c8b840`, water `#4888c8`, data `#b8b8b8`), perimeter breach (floor underwall strip exposed). Wall infrastructure layer pre-generated, invisible until breach. + +**Ozzie** names the principle: "Blank doesn't mean empty. Blank means unoccupied right now." Wear marks, recent use evidence, a torn piece of fabric on a conduit — these are the generator's promise that spaces were lived in. The historical detail layer must be uniform across the entire space, not concentrated near intended access points. + +--- + +### Directive 3: Vertical Scale + +**Fully addressed.** Deferred to v0.4 for implementation, but architecture is complete. + +**Tyre** delivers: +- `z_levels: u8` on `DistrictSkeleton` (no hard cap; engineering recommendation: 64) +- `MultiBlockReservation` extended with `z_levels: u8` for skyscraper footprints +- `FloorZone { z_level, zone_type, zone_palette, access_tier }` for per-floor assignment +- `ZLevelLoadState: Loaded | Skeleton | Ungenerated` — lazy loading (only current floor + adjacent filled) +- Stairwells and elevators as vertical spines through the building (tile positions preserved across floors) +- Target milestone: v0.4 (~7 dev-days) + +**Gestalt** adds system-level design: +- **Z-bands** (floor groupings with similar social function) as the generator's planning unit — not floor-by-floor +- Vertical access tier gradient: ground = most public; top = most restricted (monotonically non-decreasing) +- **Guarantee:** Every tall structure (z_band_count ≥ 3) must have a roof zone classified `Insider` or `BreachOnly` accessible by non-obvious route + +**Miri supplement** provides heritage-derived vertical social hierarchy: +- Corporate: bottom = labor / mid = operations / top = executive +- Commission-institutional: bottom = public services / top = secure records (inverted from corporate) +- Iron-heritage occupation: horizontal solidarity networks per floor; outsider is immediately noticed +- Cross-floor `Tactical` triangle: NPC A (floor 3) + NPC B (floor 17) + NPC C (floor 42) — player needs vertical access to work the triangle + +**Araminta** provides visual grammar: +- 4 height tiers (S1 = 1-3 floors, S2 = 4-10, S3 = 11-30, S4 = 30+) +- Shadow is the primary height signal: 2–40 visual tiles, hardness scales with tier +- Rooftop vocabulary by tier: S1 simple parapet; S2 HVAC clusters; S3 complex mechanical arrays; S4 antenna farm +- Station interior: penumbra width (ambient occlusion) replaces directional shadow + +**Ozzie** names the payoff: in top-down, vertical gives you the view DOWN. The floor-above perspective = detective overview, assassin planning view, tycoon seeing the whole market district at once. Generation sin: identical floors with just different lock levels. + +--- + +### Directive 4: Dynamic World Modification + +**Fully addressed.** Generator is immutable; modifications layer on top. + +**Core principle (Tyre + Gestalt in full agreement):** The generator produces the world as it was. Modifications are a delta layer applied at render time. + +**Tyre** provides `ChunkMutations`: +```rust +struct ChunkMutations { + tile_overrides: Vec<TileOverride>, + structural_changes: Vec<StructuralChange>, + placed_objects: Vec<PlacedObject>, + removed_objects: Vec<ObjectId>, +} +``` +Pending mutations can be applied to unvisited districts (stored at `PreparedDistrict` level, applied immediately at chunk fill time). + +**Gestalt** provides the broader `WorldStateDelta` model covering all game state changes: `StructuralDamage`, `WallBreached`, `DoorStateChanged`, `ObjectModified`, `TileTypeChanged`, `AccessTierChanged`, `NpcRemoved`. For large-scale events: soft re-generation via `original_seed XOR event_seed`. + +**Miri** provides the cultural response layer: trauma events intensify culture, not transform it. Heritage roots determine community response patterns. Active modification state decays toward baseline at heritage-root-dependent rates. NPC pattern weight shifts post-trauma: ANCHOR/WITNESS/REMNANT increase; normal distribution shifts. + +**Nigel** frames it as replayability: baseline → playthrough divergence is the key mechanism. Storyteller-activated structural fragilities produce *caused* destruction, not random damage. Player-caused destruction is private geographic knowledge (the hole in the wall exists only in your playthrough). Fragility tags should be present in <5% of infrastructure chunks per district. + +**Araminta** provides 5 destruction visual stages (Active → Fresh Aftermath → Stabilized → Reconstruction → Healed Scar) with specific hex values at each stage. Key visual: open-sky tile `#c8d8f0` at 100% brightness = "roof removed, open to sky" — the only floor-layer element brighter than ambient. Also affects gameplay: LOS changes dramatically in open-roofed areas. + +--- + +### Directive 5: Entity-Carried Chunks as Core + +**Addressed. Architectural split requires resolution (see OQ-R3-D above).** + +Cultural grammar is settled regardless of architecture choice (Miri supplement): +- Trains (`BoundedLinear`): class-sequence social grammar. Temporal pressure. Witness compactness. Social compression. +- Spaceships (between systems): institutionally suspended environment. No Commission jurisdiction. No external communication. Social roles strip. Heritage roots determine behavior in suspension (Frost: doubles down on privacy; Tide: ship community expands; Salt: grey-market window opens). +- Universal: `transit_social_modifier` adjusts trust-building rate (up), privacy level (down), enforcement level (down), internal information flow (up), external information flow (blocked until arrival). + +Ozzie names the principle: mobile environments are social pressure cookers. The journey is the content. The space changes social state over time. + +--- + +### Directive 6: Grid Breathing Both + +**Resolved.** See OQ-R3-A above. + +Summary: `DistrictLayoutMode: Grid | Organic`. Block offsets and rotations up to 45°. Streets as negative space in organic mode. Angular landmark at grid rotation seams. Heritage roots predict which mode a district uses. Mixed within district via block-level `street_geometry` assignment. + +--- + +### Directive 7: Palette Granularity + +**Resolved.** Modifier system, not more base palettes. + +**Tyre** provides the data structure: +```rust +struct ZonePalette { + base: BasePalette, + modifiers: Vec<PaletteModifier>, +} +enum PaletteModifier { + EconomicFunction(EconomicModifier), // industrial farming vs rustic farming + Era(Era), + Faction(FactionModifier), + HeritageTint(HeritageRoot), + ClimaticCondition(Climate), +} +``` + +**Araminta** expands to 8 base terrain types (T1 temperate farmland + T2 industrial/greenhouse as the two farmland types that directly address the lead directive) and provides 3 modifier axes: heritage root (structure material character), economic tier (condition/density), era (material generation) — plus optional faction overlay. Result: ~40-50 strongly differentiated visual feels; 200+ meaningfully distinct combinations. + +**Miri** provides the conceptual model: terrain type = material vocabulary (what exists here); heritage root = organizational grammar (how it's arranged, decorated, related). Full table of 10 heritage roots × organizational principle × visual signature. Base game requirement, not DLC. DLC expands variant assets; the grammar rules are core. + +--- + +### Directive 8: Not Every Place Serves Every Playstyle + +**Fully addressed.** Team converges on: mismatch is content, not failure. + +**Gestalt** formalizes with a 3-tier guarantee system: +- **Tier 1 Universal** (all inhabited): Social Hub + Informal Zone + Encounter Corridor +- **Tier 2 Full-only** (Full-complexity): Traffic Chokepoint + Institutional Space + Insider Space + Economic Node (with terrain-aware expressions) +- **Tier 3 Conditional** (parameter-dependent): Elevated Vantage, Egress Multiplicity, Temporal Opacity Window, Economic Asymmetry Signal, Power Gradient Visibility + +Guarantee audit is now conditional-aware. A Minimal-complexity farmstead gets 3 checks. A Full-complexity urban hub gets up to 11. + +**Miri** provides the playstyle affinity matrix: 15 setting types × 5 playstyles, rated Primary/Secondary/Weak/Poor. "Poor" doesn't block a playstyle — it means the generator didn't budget for it. Players who insist on weak-affinity playstyles find sparse, unsatisfying affordances. + +**Nigel** names the deeper principle: playstyle mismatch reveals what kind of world this is. Early engagement friction is discovery, not failure. The only hard requirement for Full-complexity districts: entry points for all playstyles (the 7 archetypes). Not equal depth — entry points. + +**Ozzie** confirms: the farming settlement forces the assassin to use different skills at a different pace. The mismatch makes you understand the world. Generation sin: making the backwater too empty to have any social architecture. A settlement with 80 people who've been there 40 years should have denser social graphs per capita than a transit hub with 2000 transient workers. + +--- + +### Directive 9: Insignificant as Social Lens + +**Fully addressed.** Miri delivers the five-lens analysis. + +**Miri** demonstrates same backwater (Stone/Tide, ~150 people, 40 years, one tavern, no faction presence) through five lenses: +- **Investigation:** Everything is visible. Challenge is not finding information — it's that the information knows about you. Twist: the insignificant backwater is where someone goes to HIDE. One anomalous resident who shouldn't be here. +- **Tycoon:** Land rights, water rights, one trading route. The community is captive. Economic maneuvers are conducted entirely in public. +- **Dating sim:** No anonymity phase — you're known by day three. The romantic play is not "meet and discover" but "earn belonging." +- **Political drama:** Personal-scale coalition politics at human scale. No institutional mediation. Win Aia's family, lose Torval's approval. +- **Assassination:** Locally-significant backwater has Poor affinity by default. Exception: a network-significant person gone to ground here. The hardest assignment. + +Minimum content for even Moderate-complexity insignificant districts: one anomalous presence (investigation hook), one economic chokepoint (tycoon hook), one sustained social gathering (dating sim hook), one contested allocation decision (political hook), one visitor of uncertain identity (assassination hook, latent). One NPC with sufficient complexity can provide all five simultaneously. + +**Ozzie** states the principle: the playstyle is the starting assumption the world eventually corrects. Players discover that what they were looking for is a simplification of a richer reality. *That is asymmetric awareness.* + +--- + +## Section 3: SignificanceTier / ComplexityTier / DramaDensity — Resolution + +**Substantially resolved. One naming/placement question remains for Round 4.** + +### What the Three Parameters Are + +All three participants agree on the conceptual structure: + +| Concept | What it measures | Type | +|---|---|---| +| Network significance | How important this location is in the galaxy | Static, set at system generation | +| Generator budget | How much content the generator produces here | Static, set at Phase 1 | +| Active narrative intensity | How much drama the storyteller is firing | Dynamic, storyteller-modified at runtime | + +### The Naming Dispute + +| Participant | Network significance | Generator budget | Active narrative | +|---|---|---|---| +| Gestalt | ~~SignificanceTier~~ (RETIRED) | ComplexityTier | DramaDensity | +| Tyre | SignificanceTier (RETAINED) | ComplexityTier | DramaDensity (NOT on DistrictSkeleton) | +| Nigel | WorldTier | ComplexityTier | DramaDensity | + +### What the Canonical Struct Says + +Tyre's §6.4 (the canonical DistrictSkeleton) retains `significance: SignificanceTier` as a struct field. DramaDensity is explicitly **not** on the DistrictSkeleton — it is runtime storyteller state. + +Gestalt's §6.5 retires SignificanceTier and absorbs it into ComplexityTier + network position metadata, with DramaDensity as a runtime parameter. + +### Resolution + +**Substance**: all three participants agree. The DistrictSkeleton carries two static parameters (network significance + generator budget). DramaDensity is NOT a generator output; it lives in runtime storyteller state. + +**Naming and placement**: Tyre's struct is the implementation reference. His `SignificanceTier` enum captures network significance. Nigel's `WorldTier` is a cleaner name for the same concept. **This is an open naming question for the D-record.** + +The key constraint relationship (all three agree): network significance constrains ComplexityTier ceiling; ComplexityTier constrains DramaDensity maximum; DramaDensity is always ≤ ComplexityTier capacity. + +--- + +## Section 4: Canonical DistrictSkeleton — Status + +**Produced.** Tyre §6.4 provides the authoritative Rust struct. + +### New Fields Added in Round 3 + +To the struct previously defined in Rounds 1–2: + +```rust +// Added in Round 3: +significance: SignificanceTier, // network position (naming TBD) +layout_mode: DistrictLayoutMode, // Grid | Organic +setting: SettingType, // merged SettingGeometry + TerrainType + +// On MultiBlockReservation (vertical scale): +z_band_count: u8, +floor_count: u8, +z_band_zones: Vec<ZoneDefinition>, +vertical_corridors: Vec<VerticalCorridorSpec>, + +// On SocialSitePlacement.triangles: +purposes: Vec<TrianglePurpose>, // on TriangleAssignment + +// On ZonePalette: +modifiers: Vec<PaletteModifier>, // palette modifier system + +// Added by Gestalt §10: +vertical_structure: VerticalStructure, // Flat | Medium | Tall | Skyscraper +breach_only_zones: Vec<ZoneId>, // ≥1 for Full-complexity districts +``` + +### What Is NOT on the DistrictSkeleton + +- `DramaDensity` — runtime storyteller state (all three agree) +- `assassination_difficulty` — derived descriptor on SocietyProfile (not skeleton-level) +- `transit_social_modifier` — on MobileChunk, not static district skeleton + +### Memory Budget + +Tyre estimates ~8-14 KB per district with all Round 3 additions. 300 worlds × ~6 districts × ~12 KB = ~21 MB total for all skeletons. Trivial. + +--- + +## Section 5: Items Ready to Become D-Records + +The following decisions have achieved full or near-full team consensus and are ready to be formally recorded. Listed in priority order. + +**D-READY-1: DistrictLayoutMode — Grid and Organic Support** +Tyre + Araminta + Miri + Nigel + Ozzie all agree. The D-094 hierarchy defines data sizes, not geometry. `DistrictLayoutMode: Grid | Organic { placements }` with `BlockPlacement { offset, rotation_steps (max 45°), street_width_factor }`. Adjacent districts can have different modes. Implementation cost: ~3 dev-days. + +**D-READY-2: Guarantee Tier System — Universal / Full-Only / Conditional** +Gestalt defines the 3-tier system. All participants apply it. Tier 1 (all inhabited): Social Hub + Informal Zone + Encounter Corridor. Tier 2 (Full-complexity): Traffic Chokepoint, Institutional Space, Insider Space, Economic Node. Tier 3 (conditional): Elevated Vantage, Egress Multiplicity, Temporal Opacity Window, etc. Replaces the flat 11-check audit with conditional-aware logic. + +**D-READY-3: TrianglePurpose Enum** +Gestalt + Tyre agree. `TrianglePurpose: Investigation | Economic | Social | Political | Tactical | Mundane`. Tactical = Target + Protector + Informant/Witness (assassination context). Triangles carry `Vec<TrianglePurpose>`. ~20 lines of code. + +**D-READY-4: WallBackside / TileBehindState** +Gestalt (TileBehindState: StructuralFill | HiddenRoom | Interstitial) and Tyre (WallBackside: AdjacentSpace | StructuralFill | ServiceVoid | ChunkBoundary | Exterior) are complementary, not contradictory. Combined: every wall tile is tagged; no tile in a generated chunk is ever ungenerated void; `AccessTier::BreachOnly` is a first-class access tier; Full-complexity districts guarantee ≥1 BreachOnly zone. + +**D-READY-5: Dynamic Modification via Overlay (Not Re-generation)** +Tyre (ChunkMutations) + Gestalt (WorldStateDelta) agree completely. Generator state is immutable after Phase 1. Modifications are overlays. Pending mutations at PreparedDistrict level for unvisited districts. Soft re-generation for large-scale events via `original_seed XOR event_seed`. + +**D-READY-6: ZonePalette Modifier System** +Tyre + Araminta agree. `ZonePalette { base: BasePalette, modifiers: Vec<PaletteModifier> }`. 8 base terrain types (T1 rustic farmland, T2 industrial farmland are the split that addresses the lead directive). 3 modifier axes: heritage root, economic tier, era. Optional faction overlay. + +**D-READY-7: Horizon View Corridor as Coastal Guarantee** +Araminta + Gestalt agree. Mandatory negative-space view corridor (≥8 vt unobstructed) in coastal district skeleton. Tier 2 guarantee for any district with Water terrain on one boundary. Reservation prevents warehouse placement at waterfront. + +**D-READY-8: Assassin Lens Spatial Guarantees (A-1 through A-4)** +Gestalt defines; Miri, Nigel, Ozzie confirm. Four derived guarantees (Elevated Vantage, Egress Multiplicity, Temporal Opacity Window, Non-Institutional Route). These are derived properties from existing spatial configuration — not new spaces tagged for assassins. + +**D-READY-9: Heritage Grammar Overlay for Non-Urban Palettes** +Miri + Araminta agree (cross-domain). Terrain type = material vocabulary; heritage root = organizational grammar. 10 heritage roots × organizational principle. Base game, not DLC. Modifier flags at chunk fill time. + +**D-READY-10: Non-Urban Informal Zone Typology** +Miri: informal zone redefined from institutional absence to outside community social field. Three types: social_permission / physical_distance / utilitarian_cover. Generator tags per type; heritage root determines which appears. + +**D-READY-11: Vertical Scale Architecture** +Tyre + Gestalt + Miri agree. z-levels u8 field; practical cap 64; lazy z-level loading; FloorZone per-floor assignment; z-bands as generator planning units; vertical access tier gradient; roof as mandatory discovery zone for tall structures. Target: v0.4. + +**D-READY-12: Trauma Events as EraModification Subtypes** +Miri: `ModificationType::TraumaEvent` with subtypes (PhysicalDestruction, EconomicDisruption, PoliticalShock, ViolenceEvent, MigrationShock) + `cultural_aftermath: HeritageRootResponse`. Active modification state decays at heritage-root-dependent rates. + +--- + +## Section 6: New Open Questions for Round 4 + +### OQ-R4-A: Vessel Architecture Decision (Tyre vs Nigel) + +The two proposals are architecturally incompatible: +- **Tyre:** `MobileChunk` as entity-carried ChunkData. Vessels exist as world entities. Docked state connects to static chunks. In-transit uses scrolling exterior visual buffer. Cost ~9.5 dev-days. +- **Nigel:** Instanced district model. Vessel is a district generated at journey-start, lifespan at arrival. Visual movement is client-side animation. No coordinate complexity. Cheaper and simpler but vessels don't exist between voyages. + +Questions to resolve: Do vessels need to be persistently world-present (docked at port, visible from the dock)? Or is it acceptable for vessels to only exist during voyages? This decision drives the streaming model, world map representation, and NPC simulation behavior. + +### OQ-R4-B: SignificanceTier Naming and Scope + +Gestalt retires `SignificanceTier`; Tyre retains it; Nigel proposes `WorldTier`. The substance is agreed (three orthogonal parameters). The D-record needs a canonical name and scope definition. Tyre's struct has the implementation vote. Does the team accept `significance: SignificanceTier` or rename to `world_tier: WorldTier`? + +### OQ-R4-C: Assassination Difficulty Descriptor Placement + +Miri proposes `assassination_difficulty: low/medium/high/extreme` as a derived descriptor from society profile. Open question: is this stored on the DistrictSkeleton (Phase 1 output), on the society profile itself, or computed on demand by the assassination gameplay system? Miri asks Gestalt for the integration point. + +### OQ-R4-D: Heritage Grammar Overlay Representation + +Miri's 10-row heritage grammar table needs encoding for chunk fill consumption. Miri asks Araminta: per-heritage modifier objects that chunk fill applies, or lookup tables within terrain palette assets? This affects both the authoring workflow and the chunk fill pipeline. + +### OQ-R4-E: "One NPC, Five Lenses" — Does the 10-Axis Model Cover It? + +Miri's minimum content requirement for insignificant districts (one anomalous presence, one economic chokepoint, one gathering rhythm, one contested allocation, one visitor of uncertain identity) could in theory be one NPC. Does the current 10-axis NPC model already support providing all five playstyle entry hooks simultaneously? Miri asks Nigel. + +### OQ-R4-F: Soft Re-Generation Coherence + +Gestalt's soft re-generation via `original_seed XOR event_seed` for large-scale events — does XOR-based reseeding produce visually/historically coherent results? Or does it produce results that look random rather than caused? Ozzie's principle is that destruction must be *caused*, not *random*. The cultural response model (Miri) requires that the aftermath feel like an intensification of existing character. Does XOR-seeded regeneration preserve this, or does it need a more structured approach (e.g., partial re-stamp with damage parameters)? + +--- + +## Section 7: Cross-Cutting Themes + +### 1. The Information Landscape Is the Game + +Every playstyle — investigation, tycoon, dating sim, political drama, assassination — is fundamentally an information game. The generator's job is to produce a world where information is asymmetrically distributed, where discovering it requires skill and effort, and where the same information means different things depending on what lens you're using. The generator doesn't produce five games. It produces one information landscape that five lenses read differently. + +This is the Round 3 synthesis of what the game *is*. Miri's formulation: "one NPC, five lenses." Ozzie's formulation: "asymmetric awareness — players discover that what they were looking for is a simplification of a richer reality." + +### 2. The Generator Produces Capacity; the Storyteller Fires It + +A consistent architectural boundary emerged and hardened across all participants: + +- **Generator state** (Phase 1 + Phase 2): immutable after production. Deterministic from seed. The capacity of the world — what's possible here. +- **Storyteller state** (runtime): DramaDensity, triggered modules, activated triangles, fragility explosions. The utilization of the world — what is happening here right now. +- **Delta layer** (post-generation): modifications applied by simulation events. The history of the world — what has happened to it since. + +These three layers compose at render time. The generator never re-runs. + +### 3. Organic vs Grid Is a History Statement + +Miri's worldbuilding insight, confirmed by all: grid = power imposed; organic = power negotiated. The layout mode of a district is a legible historical record of who built it and under what conditions. Commission-planned stations are grid. Pioneer settlements are organic. Old quarters that predate the Commission are organic in a way that tells you they were here before the plan. This is not just visual variety — it is *architectural history as information*. + +### 4. Vertical Is Information Asymmetry Made Spatial + +The skyscraper is a compressed information gradient. Lower floors have information about what's happening up high (the lobby worker knows who enters). Upper floors have information about what's happening below (the executive commissioned it). The vertical access challenge is the access tier system made physical. Getting to floor 30 is earning the information that lives there. + +Ozzie names it: in top-down, vertical gives you the view DOWN. The detective's overview. The assassin's planning position. The tycoon seeing the whole district. This is a distinct gameplay affordance that flat districts cannot produce. + +--- + +## Section 8: Qatux Observations + +**For the record:** + +1. **The canonical DistrictSkeleton is Tyre's §6.4.** Gestalt's §10 is the design specification (field names and semantic intent); Tyre's §6.4 is the implementation specification (Rust syntax and types). They are compatible with one exception: `SignificanceTier` vs. retired. The D-record should canonicalize the name. + +2. **DramaDensity belongs in simulation state, not the generator.** All three participants who addressed the reconciliation (Gestalt, Tyre, Nigel) agree on this. The DistrictSkeleton carries the capacity ceiling. The storyteller system carries the current value. + +3. **The vessel architecture decision is blocking.** Miri supplement provides cultural grammar for both models. Araminta's coastal palette is ready for maritime templates. Nigel's instanced district model is architecturally cleaner. Tyre's entity-carried model is more powerful but more complex. This must be decided in Round 4 — maritime and transit templates cannot be authored until the architecture is chosen. + +4. **Ozzie's 4 assassination generation sins are testable properties.** They are not design principles but verifiable spatial guarantees: omnidirectional witness coverage, converging escape routes, no vertical option, no crowd rhythm. These can be added to the guarantee audit as conditional checks. + +5. **Miri's `behind_boundary` descriptor (cultural sensitivity + social consequence) is a cross-domain requirement.** It requires coordination between: Tyre (WallBackside implementation), Miri (cultural sensitivity values per heritage root), Araminta (visual grammar for breach consequences), and Gestalt (BreachOnly guarantee and scenario instantiation). This is the kind of cross-domain dependency that needs a D-record to anchor it before templates are authored. + +6. **The 45° organic rotation cap should be explicitly stated in the D-record.** It is a hard technical constraint, not a design preference. Beyond 45°, tile-based pathfinding produces unacceptable artifacts. Players expecting flowing curves will not get them from the tile engine — they will get angular organic layouts. + +--- + +**Next:** Round 4 (if required) should resolve: vessel architecture decision (OQ-R4-A), SignificanceTier naming (OQ-R4-B), and whether the canonical struct is formally signed off for D-record production. The pipeline is architecturally sound. What remains is settling the naming disputes and the one structural split. diff --git a/docs/workshops/generator-architecture/round-4-notes.md b/docs/workshops/generator-architecture/round-4-notes.md new file mode 100644 index 000000000..f6d438fe2 --- /dev/null +++ b/docs/workshops/generator-architecture/round-4-notes.md @@ -0,0 +1,368 @@ +# Generator Architecture Workshop — Round 4 Notes + +**Workshop:** Generator Architecture (#562) +**Round:** 4 — Final Convergence +**Date:** 2026-02-27 +**Participants:** Gestalt, Tyre, Miri, Araminta, Nigel, Ozzie +**Documented by:** Qatux + +--- + +## 1. Round 4 Assignment Summary + +Round 4 had three categories of work: + +1. **Open question resolution** — all six OQs from Round 3 carried forward. All are now resolved. +2. **Concrete demonstration** — Miri's "write the NPC" exercise (OQ-R4-E). Complete. +3. **D-record sign-off** — all 12 items from the D-ready list reviewed and approved by all six participants. Two additional items (D-READY-13 and D-READY-14) identified and added. + +Pre-confirmed lead decisions (Round 4 input): +- **WorldTier** wins over SignificanceTier +- **Entity-carried MobileChunk** is core architecture +- **DramaDensity** is runtime storyteller state, NOT on DistrictSkeleton +- **Heritage grammar overlay** is base game, not DLC + +--- + +## 2. Open Question Resolutions + +### OQ-R4-A: Vessel Architecture — Resolved (Lead Directive) + +**Decision: Entity-carried MobileChunk. Tyre's architecture. Lead-confirmed.** + +All participants accepted. Tyre provided the final canonical `MobileChunk` struct. Nigel formally withdrew his instanced-district model and stated why the entity-carried model is superior for accumulating vessel history across voyages. Ozzie provided the player-experience verdict: the docked ship must physically exist and be present at the dock — this is load-bearing for the game's fundamental promise of a persistent world. + +Key rationale (Ozzie): "The ship at the dock is a trust signal. It tells me: this happened. That voyage was a real event in a real world." + +Additional vessel requirement raised by Ozzie: **departure schedules** must be a generator output. Vessels docked without scheduled departures produce port graveyards. The departure window is both world-time structure and a player urgency driver. + +### OQ-R4-B: WorldTier Naming — Resolved (Lead Directive) + +**Decision: `world_tier: WorldTier` on DistrictSkeleton.** + +`SignificanceTier` is retired. `WorldTier` correctly describes what the field measures: simulation fidelity budget allocated to this location, not narrative importance. A narratively critical backwater can be `WorldTier::Local + ComplexityTier::Full`. + +**Canonical WorldTier enum (Tyre, final):** +``` +Core — Hub system. Full simulation, high faction pressure. +Regional — Regional importance. 1–4 districts, partial full-budget. +Local — Small community. 1 district, limited budget. +Transit — Transit stop. Pass-through only, minimal simulation. +Dormant — Not simulated until player approaches. +``` + +WorldTier → ComplexityTier ceiling constraint table: + +| WorldTier | ComplexityTier max | +|-----------|-------------------| +| Core | Full | +| Regional | Full | +| Local | Moderate | +| Transit | Minimal | +| Dormant | Empty | + +### OQ-R4-C: Assassination Difficulty Descriptor — Minor Tension Noted + +**Two valid positions emerged. The tension is recorded here for the D-record.** + +**Gestalt:** `assassination_difficulty` is computed on demand — a function of `(SocietyProfile, SpatialGuarantees, StorytellerState) → DifficultyDescriptor`. Never stored. Called at contract acceptance and during pre-op planning. Rationale: the inputs include dynamic runtime state (active guard levels, current NPC distribution), so any stored value is stale. + +**Miri:** `DerivedDistrictAnalysis` struct on `DistrictSkeleton`, computed at Phase 1 from society profile parameters (observation_density × information_liquidity × aftermath_engagement). Stored, not runtime-mutable. Rationale: Phase 1 Tactical triangle instantiation logic needs it before the gameplay system runs; the cultural difficulty of assassination is stable regardless of storyteller state. + +**The actual tension:** Gestalt's position captures dynamic inputs that Miri's doesn't. Miri's position captures a Phase 1 dependency that Gestalt's doesn't address. These are not mutually exclusive: the `DerivedDistrictAnalysis` on the skeleton could provide the **cultural baseline** (static, Phase 1), while the on-demand computation combines that baseline with runtime state for the player-facing assessment. This synthesis is recommended for the D-record. + +**Miri's proposed `DerivedDistrictAnalysis` struct:** +```rust +struct DerivedDistrictAnalysis { + assassination_difficulty: AssassinationDifficulty, + assassination_target_density: u8, + primary_playstyles: [AffinityLevel; 5], +} +enum AssassinationDifficulty { Low, Medium, High, Extreme } +``` + +**Qatux flag:** The D-record should specify both the stored baseline (`DerivedDistrictAnalysis`) and the on-demand runtime computation. They serve different purposes. + +### OQ-R4-D: Heritage Grammar Overlay Representation — Resolved + +**Decision: Data-driven `HeritageGrammarOverlay` structs in TOML/YAML content files (Miri) or equivalent (Araminta). Not lookup tables in palette assets. Not hardcoded.** + +Miri proposed per-heritage `HeritageGrammarOverlay` Rust structs loaded from authored data, injected into ZonePalette at chunk fill time. Araminta proposed per-heritage TOML modifier files with the same separation: Araminta authors visual/arrangement grammar, Miri authors cultural/organizational grammar. Both converge on the same authoring model with aligned field sets. + +**Key rule:** Heritage grammar modifier is applied at Phase 2 chunk fill time (not Phase 1), with one exception: `gathering_probability` influences Phase 1 block quarter pre-assignment. + +**Authoring domain separation:** +- Miri: organizational principles, boundary character, spacing, social grammar fields +- Araminta: visual expression — object sets, arrangement algorithms, lighting temperature, overhead character + +**Shared requirement:** The `ObjectTag` vocabulary must be co-maintained across both domains. + +### OQ-R4-E: "One NPC, Five Lenses" — Resolved (Miri NPC exercise) + +**Decision: One NPC can provide all five hooks. Minimum 3 NPCs required for intra-seed replayability.** + +Miri produced **Ysabel Vorn**, a full 10-axis NPC profile for Harrow Drift (Backwater/Moderate farming settlement, Stone/Tide heritage). All five playstyle lenses demonstrated in detail. The 10-axis model covers **4.5 of 5** hooks. + +**The gap: Axis 11 (Network Footprint).** The 10-axis model cannot distinguish between an NPC who is genuinely locally insignificant and one who is locally insignificant in appearance but carries network-significant information (e.g., a hiding Commission data analyst). The assassination hook for Ysabel is only available to a player with network-level intelligence access — locally, she registers as a trusted Anchor with no enemies. + +**Proposed Axis 11:** +``` +network_footprint: Option<NetworkFootprintTag> +``` +- `None` for most procedural NPCs +- `Some(tag)` for authored scenario NPCs; records external actors, reason for significance, and access tier required to see the footprint +- Does not change local NPC behavior; enables Tactical triangle instantiation for the "false backwater" scenario type + +**Nigel's finding on emergence:** One NPC provides zero intra-seed emergent behavior (no relationships = no triangles). Minimum for meaningful replayability within a seed: **3 NPCs — one functional triangle**. Miri's five minimum content types should be distributed across a minimum triangle, not collapsed into a single NPC. + +### OQ-R4-F: Soft Re-Generation — Resolved. XOR Unanimously Rejected. + +**Decision: `DamageOverlay` (structured damage parameters) for all in-playthrough events. XOR reseeding explicitly rejected.** + +All four participants who addressed this (Gestalt, Tyre, Nigel, Ozzie) reached the same verdict by independent paths. + +**Why XOR fails (Gestalt + Tyre tile-level demonstration):** +- `original_seed XOR event_seed` produces a different block, not a damaged version of the original +- Tile positions shift, zone assignments change, the pressure regulator moves — nothing is spatially coherent with the event source +- Fails Ozzie's test: "destruction must be caused, not random" +- The result looks *replaced*, not *damaged* + +**The correct approach — `DamageOverlay`:** +```rust +struct DamageOverlay { + overlay_type: DamageOverlayType, + epicenter: ChunkLocalPos, + radius: f32, + intensity: f32, + scatter_seed: u64, // variation WITHIN damage zone only +} +enum DamageOverlayType { GasExplosion, Fire, Structural { collapse_direction }, Flooding } +``` +Per-tile damage is computed from distance to epicenter + scatter. The original chunk is unchanged. The cause is legible from the output: epicenter identifiable, damage gradient visible. + +**`RegenerationStrategy` enum (Gestalt):** +```rust +enum RegenerationStrategy { + LocalOverlay(DamageParameters), // in-playthrough — generator unchanged + SoftReseed { seed_modifier: u64 }, // scenario-boundary temporal changes only + FullReseed, // era-level discontinuities only +} +``` +**Hard rule:** In-playthrough events are ALWAYS `LocalOverlay`. `SoftReseed` and `FullReseed` are scenario-setup tools, not event responses. The generator never re-runs for player-witnessed events. + +XOR-seeding remains acceptable ONLY for district-level regeneration under a different historical assumption (Phase 1 re-run for a different era branch — not in-playthrough damage). + +--- + +## 3. Rooftop Bar Clause — Amended + +**Old guarantee:** Every tall structure (z_band_count ≥ 3) must have a roof zone classified `Insider` or `BreachOnly`, accessible by non-obvious route. + +**Problem:** This prohibited valid public social destinations (rooftop bars, observation galleries, religious sky gardens). + +**Amended guarantee — Vertical Discovery:** +```rust +enum RooftopConfig { + Restricted { + zone_class: AccessTier, // must be Insider or BreachOnly + access_route: RouteObviousness, // must be NonObvious + }, + PublicWithHiddenLayer { + primary_zone: ZoneType, // Social Hub, Economic Node, etc. + secondary_restricted: ZoneSpec, // always present; Insider or BreachOnly + }, +} +``` + +**The inviolable rule:** Every tall structure must have *something* at the top that is not fully accessible from below. The discovery layer is mandatory. The public/private split of the primary space is not. + +Heritage-root-driven default: +- Iron trade towers: `Restricted` (roof belongs to guild leadership) +- Commission institutional: `PublicWithHiddenLayer` (public observation gallery + restricted records floor) +- Frost isolated structures: `Restricted` (roof is heating systems, not a social space) + +Visual grammar (Araminta): +- Public rooftop: warm amber lighting cluster (`#f0b840`) + social furniture (tables/chairs clusters) + designed railing (`#2a2018` warm pavers) +- Restricted rooftop: cold work lights only (`#c0d0e0` directed down) + mechanical equipment (HVAC, antennae) + service hatch + +Ozzie's verdict: "Most rooftops: maintenance access and a view. Some rooftops: IT'S A BAR. The rarity is what creates the moment." + +--- + +## 4. Ysabel Vorn — The NPC Litmus Test + +**Summary of the 10-axis exercise result:** 4.5 of 5 playstyle hooks covered. + +| Axis | Miri's field | Playstyle hook | +|------|-------------|----------------| +| 1 | Behavioral Pattern (ANCHOR + REMNANT secondary) | All lenses: community position | +| 2 | Surface Motivation (water equity) | Political: the stated tiebreaker role | +| 3 | Actual Motivation (stay hidden) | Investigation + Dating Sim: the closed core | +| 4 | Vulnerability/Secret (Commission warrant, Callen file chain) | Investigation + Assassination | +| 5 | Information Access (complete settlement knowledge + aging Commission expertise) | Tycoon + Investigation | +| 6 | Trust Architecture (Stone/Tide blend, slow building) | Dating Sim: the arc | +| 7 | Routine Pattern (dawn inspection, 6-weekly boundary check) | Assassination: the window | +| 8 | Economic Position (water control lever, undisclosed evidence) | Tycoon: the entry point | +| 9 | Relationship Network (3 triangles, 1 active/2 latent) | Political + Investigation + Tactical | +| 10 | Tolerance Threshold (near-zero for exposure, declining for Kael Voss) | Dating Sim + Assassination | + +**The gap (Axis 11):** Locally, Ysabel registers as `assassination_difficulty: Extreme` (150-person tight community, Stone observation, Tide aftermath). But the Tactical triangle (Ysabel ↔ Callen's agents ↔ player) only becomes visible with network-level access. The 10-axis model as currently specified cannot flag this from local data alone. + +**New Q-record to raise:** `Q-NNN: Axis 11 (Network Footprint) — authored field for network-significant NPCs in locally-insignificant positions.` + +--- + +## 5. D-Record Sign-Off — All 12 Items + 2 New + +All 12 D-ready items from Round 3 were reviewed by all six participants. Status: **all signed off**, with amendments documented below. + +| # | Item | Status | Key amendments | +|---|------|--------|----------------| +| D-READY-1 | DistrictLayoutMode: Grid / Organic | ✅ SIGNED OFF | Araminta: 45° rotation cap is a hard technical constraint (pathfinding), not a design preference. Must be stated as non-negotiable in D-record. Nigel: Grid/Organic distribution proportion must vary per seed. | +| D-READY-2 | Guarantee Tier System | ✅ SIGNED OFF | Gestalt: added Power Gradient Visibility + Economic Asymmetry Signal as conditional Tier 3 checks. Araminta: rooftop destination clause added as Tier 2 guarantee. Nigel: archetype placement must vary in angular position across seeds, not just distance from center — this is verifiable and testable. | +| D-READY-3 | TrianglePurpose Enum | ✅ SIGNED OFF | No structural amendments. Nigel: TrianglePurpose is a multi-playstyle accessibility feature, not a replayability feature. | +| D-READY-4 | WallBackside / TileBehindState | ✅ SIGNED OFF | Gestalt: both enums are canonical and complementary — WallBackside (structural/LOS) and TileBehindState (gameplay). D-record must document both and their mapping. Araminta: Era-tagged infrastructure cavity contents (Era 1–3 bundle density) with standardized color codes. Nigel: backside assignments within a template must have seed-driven variation (not fixed-template values). | +| D-READY-5 | Dynamic Modification via Overlay | ✅ SIGNED OFF | Add `DamageOverlay` struct and `RegenerationStrategy` enum from OQ-R4-F resolution. XOR is explicitly prohibited for in-playthrough events. Araminta: trauma event → visual destruction stage mapping added (PhysicalDestruction → Stage 2; economic/political/migration → quarter fill modifier, not destruction stages). | +| D-READY-6 | ZonePalette Modifier System | ✅ SIGNED OFF | Araminta: explicitly name T1 (warm organic, natural lighting) and T2 (cool grey-green, artificial lighting) as rustic vs. industrial farmland. Nigel: palette modifiers should influence NPC appearance as well as environment. | +| D-READY-7 | Horizon View Corridor | ✅ SIGNED OFF | Araminta: clarify as negative space (instruction to not place blockers), not a placed object. Low z=2 element marks waterfront point as designed viewing location. Nigel: corridor position should vary per seed — the Wow Moment needs to be discovered, not expected. | +| D-READY-8 | Assassin Lens Spatial Guarantees (A-1 through A-4) | ✅ SIGNED OFF | Gestalt: A-1–A-3 are Tier 3 Conditional; A-4 is mandatory Full-complexity. Framing: derived properties of existing spatial configuration, not assassin-tagged features. Araminta: A-1 Elevated Vantage requires overhead-clear LOS corridor (no z=4 elements in LOS cone). Nigel: angular variation requirement is hard — guarantee audit should fail if archetype placement clusters in predictable angular positions across N seeds. | +| D-READY-9 | Heritage Grammar Overlay for Non-Urban Palettes | ✅ SIGNED OFF | Now lockable with OQ-R4-D resolved. Miri: `HeritageGrammarOverlay` struct with organizational principles. Araminta: TOML modifier files with visual expression parameters. Both specify the `ObjectTag` shared vocabulary requirement. Phase 1 exception: `gathering_probability` evaluated at block planning for quarter pre-assignment. | +| D-READY-10 | Non-Urban Informal Zone Typology | ✅ SIGNED OFF | Araminta: visual grammar per type (social_permission = gathering infrastructure present; physical_distance = sparse, unmaintained path; utilitarian_cover = functional work space with no obvious unofficial purpose). Nigel: each type demands different player strategies — the variation is in how to USE cover, not merely what it is. | +| D-READY-11 | Vertical Scale Architecture | ✅ SIGNED OFF | Add Rooftop Bar Clause (RooftopConfig enum from Section 3). Araminta: roof zone must be assigned `PublicDestination | RestrictedDiscovery` during block planning. Nigel: z-band floor boundaries must have seed-variation within cultural ordering constraints (executive always in upper zone, but which exact floor varies per seed). | +| D-READY-12 | Trauma Events as EraModification Subtypes | ✅ SIGNED OFF | Gestalt + Tyre: TraumaEvent uses LocalOverlay, not XOR reseeding. Physical destruction and cultural aftermath are separate tracks. Araminta: `trauma_visual_decay_rate: slow | medium | fast` per heritage root, with seed-variation within root baseline (Nigel). | + +### D-READY-13: MobileChunk Specification (New) + +**Status: D-READY.** The final canonical `MobileChunk` struct from Tyre (Round 4, §1/§5a) is complete and signed off. Contains: + +- Full struct with `VesselClass`, `MobileInterior`, `MobileMovementState`, `TransitSocialModifier`, `NpcPersistence` enums +- `Docked` state with `connected_chunk`, `docked_since`, `scheduled_departure` +- `InTransit` and `InterSystem` states +- Vessel template size reference (TrainCar → LargeMerchant) +- Boarding sequence implementation notes +- Memory budget: ~0.5–4 KB metadata + up to 64 KB ChunkData per vessel + +**Replayability requirements from Nigel (R-V-1 through R-V-6):** +- R-V-1: At least 50% of variable passenger slots must turn over between adjacent voyages +- R-V-2: Crew persistent, passengers variable +- R-V-3: In-transit events are voyage-seeded, not vessel-seeded +- R-V-4: Arrival time is storyteller-modifiable +- R-V-5: Interior does NOT re-generate per voyage +- R-V-6: Vessel carries ChunkMutations for accumulated damage history + +**Miri's cultural grammar:** `TransitSocialModifier` with `TransitVariant` (BoundedLinear / BoundedMobile / InterSystem) is the canonical vessel cultural layer. Heritage-root behavior tables by vehicle type and jurisdictional state. + +### D-READY-14: DamageOverlay / RegenerationStrategy (New) + +**Status: D-READY.** Produced by OQ-R4-F resolution with unanimous participant agreement. + +Covers: +- `DamageOverlay` struct (epicenter, radius, intensity, scatter_seed) +- `DamageOverlayType` enum (GasExplosion, Fire, Structural, Flooding) +- `RegenerationStrategy` enum (LocalOverlay / SoftReseed / FullReseed) +- Hard constraint: in-playthrough events are ALWAYS LocalOverlay +- Hard prohibition: XOR-seeding for in-playthrough events is explicitly rejected +- Per-tile damage computation (distance from epicenter × scatter → tile modification) + +--- + +## 6. New Open Questions for Sprint Work + +| Q-ID | Question | Owner | Priority | +|------|----------|-------|----------| +| Q-NNN-a | Axis 11 (Network Footprint) — authored field for network-significant NPCs in locally-insignificant positions | Miri | High — affects assassination scenario instantiation | +| Q-NNN-b | Departure schedule model — departure windows as generator output for docked vessels | Tyre + Miri | High — vessel persistence requires it (Ozzie requirement) | +| Q-NNN-c | Mobile environment social arc — structural representation of the journey timeline (who talks to whom at which journey stage) | Miri + Gestalt | Medium — Ozzie: "the journey is content; if the social arc isn't structured, the content is random" | +| Q-NNN-d | DramaDensity enum naming — Tyre's Round 4 struct uses Quiescent/Active/Intense (3 values) vs. Round 3's Zero/Low/Medium/High/Flashpoint (5 values). Which is canonical? | Tyre + Gestalt | Low — naming only, but should be settled before D-record | +| Q-NNN-e | ObjectTag vocabulary co-maintenance — shared between Miri's HeritageGrammarOverlay and Araminta's asset categorization system | Miri + Araminta | Medium — needed for heritage grammar implementation | + +--- + +## 7. Final Canonical Structures (Summary) + +### DistrictSkeleton — Final (Tyre §5) + +New fields since Round 3: +- `world_tier: WorldTier` (renamed from `significance: SignificanceTier`) +- `grid_orientation: f32` (district rotation from world-north) +- `vertical_structure: VerticalStructure` (Flat / Medium / Tall / Skyscraper) +- `breach_only_zones: Vec<ZoneId>` (AccessTier::BreachOnly zones explicitly tracked) +- `derived_analysis: DerivedDistrictAnalysis` (assassination_difficulty, target_density, playstyle affinities) — Miri's addition + +DramaDensity remains absent from DistrictSkeleton. Confirmed by all participants. Lives in `DistrictRuntimeState` in the simulation module. + +### Three-Layer Model — Locked (Gestalt §5) + +``` +GENERATOR STATE (immutable after Phase 1) +├── Phase 1: DistrictSkeleton +│ ├── world_tier: WorldTier +│ ├── complexity_tier: ComplexityTier +│ ├── layout_mode: DistrictLayoutMode (Grid | Organic) +│ ├── guarantee_audit: GuaranteeAuditResult (3-tier) +│ ├── rooftop: RooftopConfig (per MultiBlockReservation) +│ ├── derived_analysis: DerivedDistrictAnalysis +│ └── society_profile: SocietyProfileRef +└── Phase 2: PreparedDistrict + ├── SocialSitePlacement (triangles with Vec<TrianglePurpose>) + ├── NpcManifest (seeded from society_profile) + ├── ZonePalette assignments (base + heritage modifiers) + └── ChunkMutations pending + +SIMULATION STATE (runtime storyteller) +├── DistrictRuntimeState.drama_density: DramaDensity +├── active_triangles: Vec<TriangleId> +├── npc_pattern_weights: NpcPatternWeightSet +└── assassination_difficulty on-demand computation + (SocietyProfile + spatial_audit + StorytellerState → DifficultyDescriptor) + +DELTA LAYER (post-generation) +├── DamageOverlay / ChunkMutations::LocalOverlay +├── NpcRemoved / NpcStateChanged +├── AccessTierChanged +└── WorldStateDelta (composed from all active mutations) +``` + +--- + +## 8. Memory Budget — Final + +| Component | Size per district | +|-----------|------------------| +| Identity + WorldTier + ComplexityTier | ~96 bytes | +| Blocks (4×4 × BlockSkeleton) | ~2 KB | +| Social sites + triangles | ~1–4 KB | +| Reservations + corridors (incl. z-bands) | ~0.5–3 KB | +| Boundaries | ~4 KB | +| Society profile ref | ~32 bytes | +| Zone palette | ~0.5–1 KB | +| Guarantee audit (3-tier expanded) | ~512 bytes | +| Layout mode (Organic) | 0–1 KB | +| VerticalStructure + breach zones + derived_analysis | ~128 bytes | +| **Total per district** | **~9–16 KB** | + +Fleet: 300 worlds × ~6 districts × ~13 KB = **~23 MB** +Mobile chunks: ~3 MB at 50 active entities (paged by streaming model) +Grand total: ~26 MB — within accepted RAM budget + +--- + +## 9. Qatux Observations + +**For the record:** + +1. The XOR rejection is the single most unanimously confirmed decision of this workshop. All four participants who addressed it reached the same verdict by independent reasoning. The D-record should state the prohibition unambiguously. + +2. The assassination_difficulty tension (Gestalt: computed-on-demand vs. Miri: DerivedDistrictAnalysis at Phase 1) is resolvable by synthesis: stored cultural baseline + on-demand runtime computation for player-facing assessment. This should be explicit in the D-record rather than left as a gap. + +3. Ozzie's two additions to D-READY (departure schedules + mobile environment social arc) are requirements, not preferences. Both are downstream of the vessel persistence decision (D-READY-13). They should be raised as Q-records with high priority. + +4. The Ysabel Vorn exercise is the single most complete demonstration of the NPC generation model produced in this workshop. It should be referenced in the NPC system D-record as the canonical litmus test case for validating the 10-axis model. + +5. Tyre's DramaDensity enum in Round 4 (Quiescent/Active/Intense, 3 values) differs from Round 3's (Zero/Low/Medium/High/Flashpoint, 5 values). This naming gap should be resolved before the D-record is written. + +6. WorldTier enum values are now canonical from Tyre's Round 4 output: Core / Regional / Local / Transit / Dormant. The Round 3 naming (CenterStage/Regional/Backwater/Waypoint/Insignificant) is superseded. + +--- + +**Round 4 closes with all OQs resolved, all 12 D-records signed off (with amendments), 2 new D-records added, and 5 new Q-records raised for sprint work. The pipeline is locked. D-record production proceeds.** diff --git a/docs/workshops/generator-architecture/round-5-notes.md b/docs/workshops/generator-architecture/round-5-notes.md new file mode 100644 index 000000000..1a5ffb5db --- /dev/null +++ b/docs/workshops/generator-architecture/round-5-notes.md @@ -0,0 +1,209 @@ +# Generator Architecture Workshop — Round 5 Notes + +**Workshop:** Generator Architecture (#562) +**Round:** 5 — Final Review +**Date:** 2026-02-27 +**Compiled by:** Qatux + +--- + +## Purpose + +Round 5 was a sign-off round. All six participants reviewed `workshop-outcomes.md` for accuracy against their Round 4 canonical outputs. No new design proposals were made. Corrections only. + +--- + +## Sign-Off Status + +| Agent | Role | Status | Corrections | +|-------|------|--------|-------------| +| Tyre | Technical Architect | Signed off with corrections | HIGH: WorldTier enum; MEDIUM: DistrictSkeleton fields; LOW: MobileMovementState | +| Miri | Worldbuilder | Signed off with corrections | D-READY-10 heritage correlations; D-READY-12 principle | +| Araminta | Visual Designer | Signed off with corrections | D-READY-6 terrain types; D-READY-9 domain; D-READY-13 vessel grammar; D-READY-5 stages | +| Nigel | Replayability Advocate | Signed off with corrections | Missing ComplexityTier→DramaDensity ceiling | +| Gestalt | Systems Design | Signed off with minor notes | complexity→complexity_tier naming | +| Ozzie | Player Experience | Signed off with one correction | D-READY-11 rooftop "determines" → "weights probability" | + +All six sign-offs confirmed. Workshop is closed. + +--- + +## Corrections Applied to workshop-outcomes.md + +### C-R5-1 — WorldTier Enum Variant Names (HIGH) +**Source:** Tyre Round 5, Correction 1 + +The outcomes document used simplified/incorrect variant names. Corrected to Tyre's Round 4 canonical: + +| Incorrect | Correct | +|-----------|---------| +| Core | Epicenter | +| Regional | Regional (unchanged) | +| Local | Backwater | +| Transit | Passage | +| Dormant | Waypoint | + +Constraint ceiling also corrected: +- **Backwater → Full allowed** (key game design insight: dense isolated community, network-insignificant ≠ budget-capped) +- **Passage → Moderate** (was: Transit → Minimal) +- **Waypoint → Minimal** (was: Dormant → Empty) + +The original text "Local → Moderate max" materially prohibited the Backwater+Full case, which is one of the most important design combinations in the game. + +--- + +### C-R5-2 — ComplexityTier → DramaDensity Ceiling (MEDIUM) +**Source:** Nigel Round 5, Correction 2 + +The constraint chain was stated only halfway. Added second half: + +> ComplexityTier → DramaDensity ceiling: Full → any intensity; Moderate → Active max; Minimal → Quiescent max; Empty → Zero only (no storyteller activation possible). + +A `ComplexityTier::Empty` district has no social fabric. The storyteller cannot activate drama there. + +--- + +### C-R5-3 — DistrictSkeleton Field List (MEDIUM) +**Source:** Tyre Round 5, Correction 2 + +Five identity/context fields missing from the Phase 1 diagram, replaced by fields from other participants' proposals without attribution. Added: +- `district_id: DistrictId` +- `seed: u64` +- `district_type: DistrictType` +- `context: DistrictContext` +- `access_points: Vec<AccessPoint>` + +Added "(source: multi-participant)" notes to `vertical_structure` and `breach_only_zones` (present in outcomes but not in Tyre's canonical struct). Added "(source: Miri/Gestalt; Phase 1 computed)" to `derived_analysis`. + +--- + +### C-R5-4 — D-READY-10 Heritage Root Correlations (HIGH) +**Source:** Miri Round 5 + +The heritage root ↔ informal zone type mapping was factually wrong: +- **Dust** was listed under `utilitarian_cover` — incorrect. Dust communities have maximum communal observation; the only privacy available is negotiated. Dust → `social_permission`. +- **Iron** was missing entirely. Labor function covers presence in Iron communities. Iron → `utilitarian_cover`. + +Corrected mapping: Frost/Stone → `physical_distance`; Tide/Vine/Dust → `social_permission`; Iron/Salt → `utilitarian_cover`. + +--- + +### C-R5-5 — D-READY-11 Rooftop Config Assignment (MEDIUM) +**Source:** Ozzie Round 5 + Araminta Round 5 (confirming) + +"Heritage root determines which config is assigned" is wrong. Full determination kills the discovery moment — a Frost building with a rooftop bar is memorable *precisely because* it is unexpected. + +Corrected: Heritage root **weights the probability** between `Restricted` and `PublicWithHiddenLayer`. The final config is seeded per-building. A minority of buildings of any heritage root must be configurable as the non-dominant type. + +--- + +### C-R5-6 — D-READY-13 MobileMovementState Missing Idle (LOW) +**Source:** Tyre Round 5, Correction 4 + +`MobileMovementState` was listed as `(Docked / InTransit / InterSystem)`. Tyre's Round 4 canonical includes a fourth state: + +- `Idle` = vessel parked at a location but not docked to infrastructure (anchored ship, grounded shuttle) + +Added to D-READY-13. + +--- + +### C-R5-7 — D-READY-13 Missing Vessel Visual Grammar Reference (MEDIUM) +**Source:** Araminta Round 5, Correction 3 + +D-READY-13 specified vessel structure and cultural grammar but had no source for how vessels look different from buildings. Added reference to Araminta's five-rule vessel visual grammar (`araminta-round4.md` §2): + +1. Exterior hull uses vessel-identity material, not zone palette +2. Window tiles reveal exterior context (docked vs. in transit) +3. Compression modifier tightens proportions throughout +4. Section transitions use vessel-identity threshold elements +5. Class stratification expressed through proportion, not palette change + +--- + +### C-R5-8 — D-READY-6 Terrain Type Numbering T5/T7 Transposed (MEDIUM) +**Source:** Araminta Round 5, Correction 1 + +The outcomes document had T5 = mountain and T7 = wetland. Neither is correct per Araminta's Round 3 specification: +- T5 = Coastal water (the terrain type referenced by D-READY-7's horizon view corridor guarantee) +- T6 = Beach/coastal margin +- T7 = Mountain/high terrain +- T8 = Desert/arid + +"Wetland" was never in the original 8 types. Added note: if wetland terrain is needed, it requires design work as a new T9. + +--- + +### C-R5-9 — D-READY-9 Araminta's Authoring Domain Incomplete (MEDIUM) +**Source:** Araminta Round 5, Correction 2 + +Araminta's authoring domain was listed as "object sets, arrangement algorithms, lighting temperature." The full domain covers additional visual expression fields she specified in her Round 4 TOML schema: +- Floor surface variants (`[floor].variant_preference`) +- Overhead flora density and character (`[overhead].density_factor`, `[overhead].character`) +- Wall/structure material character (`[structure].primary_material`, `material_tone_shift`) +- Boundary material type (`[boundaries].fence_type`) + +Updated domain description accordingly. + +--- + +### C-R5-10 — D-READY-5 Destruction Stages and Palette Constraint (MEDIUM) +**Source:** Araminta Round 5, Correction 4 + +D-READY-5 referenced "Stage 2" and "Stage 3" without enumerating the full sequence. Added: + +| Stage | Name | Visual state | +|-------|------|-------------| +| 1 | Active | DamageOverlay rendering live | +| 2 | Fresh Aftermath | Structure breached; scorch, rubble, debris | +| 3 | Stabilized | Debris cleared; structural state permanent | +| 4 | Reconstruction | Scaffolding tiles, incomplete floors | +| 5 | Healed Scar | Functional; residual visual tells remain | + +Added destruction palette constraint: corruption-only (no new colors introduced by destruction; single exception: `#c8d8f0` open-sky tile when roof removed). + +--- + +### C-R5-11 — D-READY-12 Trauma Intensification Principle (MINOR) +**Source:** Miri Round 5 + +Added design principle framing to D-READY-12: + +> Trauma intensifies culture, it does not transform it. A stressed community becomes a more concentrated version of itself. Decay is toward the community's pre-trauma baseline, not toward a new equilibrium. + +--- + +### C-R5-12 — complexity → complexity_tier Field Naming (MINOR) +**Source:** Gestalt Round 5, Correction 2 + +`complexity: ComplexityTier` in the Phase 1 diagram corrected to `complexity_tier: ComplexityTier` to parallel `world_tier` naming convention. + +--- + +### Minor Notes Applied + +- **Q-NNN-b** (departure schedule model): Added note that D-READY-13 resolves this — recommend closing before sprint planning. +- **Q-NNN-f** (assassination difficulty synthesis): Clarified that on-demand computation is display-only; game logic uses Phase 1 `DerivedDistrictAnalysis` value. +- **Key Tensions table**: Updated WorldTier canonical values from old names to new. + +--- + +## Notes for D-Record Filing + +The following items were identified as issues in the D-records to be filed, not errors in the outcomes document: + +- **`GuaranteeAuditResult` struct** (Tyre Round 5, Correction 6): Tyre's Round 4 struct was missing `non_institutional_route`, `egress_multiplicity`, `horizon_view_corridor`, `breach_only_zone`, `rooftop_discovery` in the Tier 2/3 sections. These should be added when filing the D-record for D-READY-2/D-READY-8. +- **`Docked` state struct** (Tyre Round 5, Correction 5b): The outcomes doc asserted `scheduled_departure` existed in the canonical struct, but Tyre's Round 4 struct was incomplete. Corrected to clarify these fields must be added at implementation time. Both `docked_since: SimTick` and `scheduled_departure: Option<SimTick>` are required — applied directly to D-READY-13 text. + +--- + +## Post-Correction Status + +- Workshop-outcomes.md: all 12 corrections applied. Document is accurate for all six domains. +- 14 D-records remain ready for filing. +- Q-NNN-b recommended for closure (resolved by D-READY-13). +- Q-NNN-d (DramaDensity naming 3 vs 5 levels) remains open for sprint work. Nigel preference on record: preserve `Flashpoint` as distinct peak value. + +--- + +*Round 5 complete. Workshop closed.* diff --git a/docs/workshops/generator-architecture/tyre-round1.md b/docs/workshops/generator-architecture/tyre-round1.md new file mode 100644 index 000000000..02d52891b --- /dev/null +++ b/docs/workshops/generator-architecture/tyre-round1.md @@ -0,0 +1,472 @@ +# Round 1: Tyre — Technical Constraints on Generator Architecture + +**Workshop:** Generator Architecture (#562) +**Agent:** Tyre (Technical Architect) +**Date:** 2026-02-27 + +--- + +## 1. Hard Technical Constraints on Spatial Hierarchy + +These constraints are **non-negotiable** — they flow directly from confirmed D-records and cannot be relaxed without amending those decisions. + +### 1.1 Chunk: 64×64 sim tiles (32×32 visual, 32m) — D-094 + +The chunk is the **streaming and serialization unit**. This is locked. + +| Property | Value | Source | +|----------|-------|--------| +| Sim tiles | 64×64 | D-094 | +| Visual tiles | 32×32 | D-094, D-066 (2x retina) | +| Physical size | 32m × 32m | D-066 (0.5m/sim tile) | +| Memory per chunk | ~21 KB raw tile data | 64×64 tiles × 5 bytes/tile (type + flags + occupancy) ≈ 20,480 bytes | +| Serialization format | MessagePack | D-020 | + +**Why 64×64 sim is the floor:** Shadowcasting (D-035) operates at sim resolution. A smaller chunk means more cross-chunk boundary queries during LOS computation. At 64×64, a single chunk covers the full LOS radius of most entities (~20-30 sim tiles) without requiring neighbor lookups for most casts. Halving to 32×32 sim would roughly quadruple the frequency of cross-chunk shadowcasting — measurable cost on the critical path. + +**Why 64×64 sim is the ceiling (for now):** Larger chunks waste bandwidth for partial visibility. The ObserverSnapshot (D-020) sends only visible state. A 128×128 chunk would mean loading 4× the data when only a corner is visible. The 64×64 sweet spot minimizes the ratio of loaded-but-invisible tiles. + +### 1.2 Block: 128×128 sim tiles (2×2 chunks, 64m) — D-094 + +The block is the **generator planning unit**. Four chunks arranged in a 2×2 grid. + +| Property | Value | +|----------|-------| +| Sim tiles | 128×128 | +| Visual tiles | 64×64 | +| Chunks | 4 (2×2) | +| Physical size | 64m × 64m | + +**Generator implication:** The block is where the generator decides the building footprint strategy. Four chunks can: +- Remain independent (4 small buildings/spaces) +- Merge 2 horizontally or vertically (1×2 building spanning 64×32 sim tiles) +- Merge 2 in L-shape (building occupying 3 of 4 chunks with gap) +- Merge all 4 (single large building spanning the full 128×128 sim tiles) + +This is a 2-bit decision per chunk pair (merge/don't merge on each axis), producing a tractable combinatorial space for the generator without requiring variable-size building footprints. + +### 1.3 District: 512×512 sim tiles (4×4 blocks, 256m) — D-094 + +| Property | Value | +|----------|-------| +| Sim tiles | 512×512 per z-level | +| Visual tiles | 256×256 | +| Blocks | 16 (4×4) | +| Chunks | 64 (8×8) | +| Z-levels | 3 (Transit District; variable for other types) | +| Memory per z-level | ~1.35 MB (64 chunks × ~21 KB) | +| Memory for 3 z-levels | ~4 MB | + +### 1.4 Hierarchy Depth: Exactly 4 Levels + +The hierarchy is **Region → District → Block → Chunk**. No more, no fewer. + +**Why not deeper (sub-chunk quarters)?** The workshop brief mentions a "sub-chunk quarter system" (¼ chunk = 32×32 sim tiles). *cracks knuckles* — let me be honest about what this means technically. + +A 32×32 sim tile quarter is 16×16 visual tiles = 16m. That's actually a reasonable building footprint (The Last Shift bar is 28×22 visual). But: + +1. **The quarter is NOT a hierarchy level — it's a fill rule.** The chunk remains the streaming unit. Quarters are a layout constraint within a chunk, not a separately loaded/serialized entity. The generator decides how to fill a chunk's 64×64 sim space using quarter-aligned placement rules, but the server still loads/saves/streams the full chunk. + +2. **Quarter merge rules are purely generator-side.** The server doesn't know or care about quarters after generation. It sees tiles. The quarter concept exists only during the generation pass and in the template metadata. + +3. **Adding a 5th hierarchy level (quarter) to the runtime would violate D-012's streaming model.** Chunk is the streaming atom. Sub-chunk streaming would require partial chunk updates over the wire, complicating the ObserverSnapshot and the client's tile map management for zero gameplay benefit. + +**Recommendation:** Quarters are a **generation-time layout constraint**, not a spatial hierarchy level. The hierarchy stays at 4 levels. The quarter system is a set of placement rules the chunk-fill stage of the generator uses internally. + +**Why not shallower?** Removing blocks (District → Chunk directly) loses the generator's "what goes in this 64m² area" planning step. The block is where multi-chunk building footprints are decided. Without it, the generator must either think in individual chunks (losing building coherence) or in full districts (losing locality). The 2×2 block is the minimum viable planning unit for building-scale decisions. + +--- + +## 2. Data Structure for the District Skeleton (Q-036) + +The district skeleton is the generator's output from the district-generation stage. It describes **what** a district contains and **where things go**, without specifying individual tiles. + +### 2.1 Proposed Data Structure + +```rust +/// The district skeleton — atomic output of the district generation stage. +/// This is a planning artifact consumed by the block/chunk fill stages. +struct DistrictSkeleton { + /// Unique district identifier (world-scoped) + district_id: DistrictId, + + /// Generator seed for deterministic reproduction + seed: u64, + + /// District classification driving template selection + district_type: DistrictType, // e.g., Transit, Residential, Commercial, Industrial, Administrative, Medical + + /// Economic/political context from pipeline stages above + context: DistrictContext, + + /// The 4×4 block grid — each block has a zoning assignment + blocks: [[BlockSkeleton; 4]; 4], + + /// Social sites placed within this district (D-025) + social_sites: Vec<SocialSitePlacement>, + + /// Multi-block structure reservations (structures spanning >1 block) + reservations: Vec<MultiBlockReservation>, + + /// Access topology — gate/entrance placement and connectivity + access_points: Vec<AccessPoint>, + + /// Corridor/thoroughfare spine connecting access points + corridors: Vec<CorridorSpine>, + + /// Z-level configuration + z_levels: u8, + + /// Zone palette assignments (fog tints, surface colors per D-093) + zone_palette: Vec<ZoneDefinition>, +} + +struct DistrictContext { + /// Faction controlling this district (affects templates, NPC generation) + faction_control: FactionId, + + /// Economic prosperity tier (0-4, affects object density, building quality) + prosperity: u8, + + /// Population density target (NPCs per block, guides NPC slot allocation) + population_density: PopulationDensity, // Sparse/Normal/Dense/Packed + + /// Cultural ingredients (Q-032) driving visual/naming variation + cultural_profile: CulturalProfile, + + /// Transport adjacency — which access points connect to what + transport_links: Vec<TransportLink>, +} + +struct BlockSkeleton { + /// Block position in the 4×4 grid (0-3, 0-3) + position: (u8, u8), + + /// Primary zoning type for this block + zoning: ZoningType, // Residential, Commercial, Industrial, Institutional, Mixed, Open/Park, Infrastructure + + /// Whether this block is claimed by a multi-block reservation + reservation: Option<ReservationId>, + + /// Chunk merge strategy for this block (how the 4 chunks combine) + chunk_layout: ChunkLayout, + + /// Social sites hosted in this block (references into district's social_sites vec) + hosted_sites: Vec<SocialSiteId>, +} + +/// How the 4 chunks within a block are organized +enum ChunkLayout { + /// All 4 chunks independent (small buildings, mixed use) + Independent, + + /// Two chunks merged horizontally, two independent + /// Contains: which pair merges (N or S row), orientation + MergeH { row: MergeRow }, + + /// Two chunks merged vertically, two independent + MergeV { col: MergeCol }, + + /// L-shaped merge (3 chunks), one independent + LShape { corner: Corner }, + + /// Full merge (single large building spanning all 4 chunks) + FullMerge, + + /// Custom layout (for multi-block reservations that span into this block) + Reserved, +} + +struct SocialSitePlacement { + /// Social site identifier + site_id: SocialSiteId, + + /// Template tag selecting from the D-025 template library + template_tag: String, // e.g., "logistics_hub", "bar", "residential_cluster" + + /// Block(s) this site occupies + blocks: Vec<(u8, u8)>, + + /// Specific chunk(s) within those blocks + chunks: Vec<ChunkCoord>, + + /// NPC slot allocation (how many NPCs this site supports) + npc_slots: NpcSlotAllocation, + + /// Access tier for entry (D-028 Layer 1) + access_tier: AccessTier, // Public, SemiPublic, SemiPrivate, Private, Restricted + + /// Triangle templates to instantiate at this site (D-024, D-087) + triangles: Vec<TriangleTemplate>, + + /// Economic function (what this site does in the district economy) + economic_function: EconomicFunction, +} + +struct NpcSlotAllocation { + /// Named roles (authored, specific function) + named_roles: Vec<RoleSlot>, + + /// Generic background population slots (Tier 3) + background_slots: u16, + + /// Total NPC capacity at peak hours + peak_capacity: u16, +} + +struct MultiBlockReservation { + /// Reservation identifier + id: ReservationId, + + /// Template for the multi-block structure + template_tag: String, // e.g., "gate_terminal", "park", "stadium" + + /// Blocks claimed by this reservation (coordinates in the 4×4 grid) + footprint: Vec<(u8, u8)>, + + /// Whether this reservation crosses into a neighboring district + cross_district: bool, + + /// Z-levels occupied + z_range: (u8, u8), +} + +struct AccessPoint { + /// Position on the district boundary (edge + offset) + edge_position: EdgePosition, + + /// What this connects to (transit stop, neighboring district, gate) + connects_to: ConnectionTarget, + + /// Access tier (public entrance, restricted, staff only) + access_tier: AccessTier, + + /// Width in visual tiles (constrains throughput and NPC flow) + width_vt: u8, +} +``` + +### 2.2 Size Estimate + +Per district skeleton: +- 16 BlockSkeletons: ~16 × 64 bytes = ~1 KB +- Social sites (4-8 per district): ~8 × 256 bytes = ~2 KB +- Multi-block reservations (0-3): ~3 × 128 bytes = ~384 bytes +- Access points + corridors: ~1 KB +- Context + metadata: ~512 bytes +- **Total: ~5 KB per district skeleton** + +For 300 worlds × avg 6 districts = 1,800 district skeletons = **~9 MB**. Trivial. Entire galaxy skeleton fits in memory. + +### 2.3 Relationship to D-025 Social Sites + +The generator does **not** invent new social site types. It: +1. Selects from the D-025 template library based on zoning type and district context +2. Places templates onto blocks/chunks using the spatial hierarchy +3. Allocates NPC slots per template requirements +4. Wires access topology (which sites connect to which corridors) + +**D-025 templates are authored. Skeleton placement is generated.** The generator arranges templates, not tiles. + +--- + +## 3. How Chunk Loading (D-012) Constrains the Spatial Hierarchy + +### 3.1 Streaming Radius + +The player's chunk loading radius determines how much of the district is live at any time. Current constraints: + +| Parameter | Value | Source | +|-----------|-------|--------| +| Player vision range | ~20-30 sim tiles (LOS) | D-035 shadowcasting | +| Sound range | Close: 5 sim tiles, Mid: 15, Far: 30 | D-018 | +| Chunk size | 64 sim tiles | D-094 | + +**Loading strategy:** 3×3 chunk grid centered on player = 9 chunks loaded. This covers 192×192 sim tiles (96m radius in each direction from center), safely beyond max LOS range. The player never sees a chunk boundary seam. + +**Memory at 3×3 loading:** 9 chunks × ~21 KB = ~189 KB per z-level, ~567 KB for 3 z-levels. With entity data overlay: ~1-2 MB. Trivial. + +### 3.2 Cross-Chunk Constraints on Generation + +The generator must guarantee **tile continuity at chunk boundaries**. When two chunks are adjacent (whether in the same block or across blocks), their edge tiles must be compatible: +- Wall segments must align or leave matching gaps (doors) +- Floor types must transition cleanly (corridor entering a room) +- Z-level connections (stairs, ramps) must align vertically + +This is the hardest constraint on chunk-based generation. Two approaches: + +**Option A: Edge contracts.** Each chunk face exports a set of "connection points" (door positions, corridor widths). The generator plans connections at the block level, then each chunk fill respects its edge contracts. *This is what I recommend.* It's how Wave Function Collapse and similar systems handle tile boundaries. + +**Option B: Overlap zones.** Chunks share a 2-4 tile overlap strip with their neighbors. The generator fills the overlap first, then fills inward. Simpler conceptually but wastes tile real estate (up to 12.5% of each chunk at 4-tile overlap on all edges). + +**Recommendation: Edge contracts (Option A).** Each chunk face has a fixed set of connection slots (e.g., 1-3 connections per face, each defined by position + width + access tier). The block-level planning stage determines which faces connect and where. The chunk-fill stage reads its face contracts and fills interior tiles accordingly. + +### 3.3 Chunk Loading vs. Generator Computation + +D-012 specifies that chunks load/unload around the player. For generated worlds, this means chunks must be **generatable on demand** when first entered, then cached. + +**Generation pipeline timing:** + +| Stage | When it runs | Output | +|-------|-------------|--------| +| Galaxy → System → District skeletons | Game start (from seed) | All 1,800 district skeletons | +| Block planning (per district) | On first visit to district OR game start for home district | 16 BlockSkeletons with layouts + edge contracts | +| Chunk fill (per chunk) | On entering loading radius | Tile data for one 64×64 chunk | + +**Chunk fill time budget:** The player moves at Walk speed = 1 tile/2 ticks = 1 tile/200ms (at 10 tps). Crossing a 64-tile chunk takes ~12.8 seconds. A new chunk enters the 3×3 loading grid roughly every 6-12 seconds. **The chunk fill generator has a budget of ~500ms per chunk** (generous — can use background thread, D-010 deterministic sim doesn't constrain client-side gen). + +At 64×64 = 4,096 tiles, that's ~122 microseconds per tile. Feasible. Template-based fill (stamp a pre-authored room into a quarter, decorate procedurally) will be well under budget. Full WFC at this scale takes ~10-50ms in optimized Rust. + +### 3.4 Borderless Generation Implication + +D-012 states the boundary can be removed for borderless worlds. For the generator, this means: +- District skeletons must be generatable from neighbors' edge contracts (a new district skeleton can be created when the player approaches an ungenerated district boundary) +- The 4×4 block grid is the district's internal structure; the inter-district boundary is just another set of edge contracts +- **The generator pipeline must be able to run the district skeleton stage for a single district in isolation, given only its neighbors' access points as input** + +This doesn't affect v0.1 (bounded, hand-authored) but constrains the generator architecture: district generation must be local, not global. + +--- + +## 4. Performance Implications of Hierarchy Depth + +### 4.1 Lookup Complexity + +Converting a sim tile position to its hierarchy location: + +``` +Chunk coord: (x / 64, y / 64) — 1 division +Block coord: (chunk_x / 2, chunk_y / 2) — 1 division +District coord: (block_x / 4, block_y / 4) — 1 division +``` + +All integer divisions by powers of 2 = **bit shifts**. O(1) per lookup, ~3 nanoseconds. Hierarchy depth has zero performance impact on spatial lookups. + +### 4.2 Spatial Queries (Pathfinding, LOS) + +Pathfinding operates at sim-tile resolution within the loaded chunk grid. The hierarchy doesn't affect pathfinding cost directly. However: + +- **Block-level precomputation:** The generator can precompute a block-level connectivity graph (which blocks connect to which, through which access points). This gives A* a coarse-grid initial path (~16 nodes per district) before refining to tile-level within the relevant chunks. **Saves 90%+ of pathfinding work for long paths.** +- **District-level precomputation:** Same idea at district scale. For cross-district travel, the pathfinder walks the district connectivity graph (~6 districts per station), then block graph, then tile graph. Three-level hierarchical A*. + +**Performance estimate for hierarchical A*:** + +| Path type | Nodes searched | Time estimate | +|-----------|---------------|---------------| +| Within-chunk | ~100-500 tiles | <1ms | +| Within-block (cross-chunk) | 4 chunks × ~200 tiles | ~2-5ms | +| Within-district (cross-block) | 16 blocks × 4 chunk entries | ~1-3ms (coarse) + ~5ms (refine) | +| Cross-district | 6 districts × 16 block entries | ~2ms (coarse) + ~8ms (refine) | + +All well within the 100ms tick budget (D-031). The hierarchy **helps** pathfinding by providing natural coarse-graining. + +### 4.3 Memory Layout + +The hierarchy maps naturally to a flat array with computed indices: + +```rust +/// All chunks in a district, flat array indexed by (x, y, z) +struct DistrictChunks { + /// 8×8 chunks per z-level, up to 8 z-levels + chunks: Vec<ChunkData>, // indexed as z * 64 + y * 8 + x +} +``` + +Cache-friendly, contiguous, no pointer chasing. 64 chunks per z-level fit in ~1.3 MB — easily fits in L2 cache for spatial queries. + +### 4.4 What If We Added More Levels? + +| Depth | Levels | Cost | Benefit | +|-------|--------|------|---------| +| 3 | Region → District → Chunk | Loses building-scale planning | Simpler generator | +| **4** | **Region → District → Block → Chunk** | **Current. Balanced.** | **Building-scale planning + streaming** | +| 5 | + Sub-chunk quarter | Quarter = extra indirection at fill time | Finer fill control | +| 6 | + Room | Individual room tracking | Overkill — rooms are tile patterns | + +**Verdict:** 4 levels is the sweet spot. Quarters are a fill-time concept, not a hierarchy level. Going deeper adds complexity without proportional benefit. + +--- + +## 5. v0.1 Stub Interfaces for the Generator + +v0.1 is hand-authored (D-036, D-093). The generator doesn't run. But the data structures and interfaces it will consume must exist as stubs now, or v0.2+ work will require a rewrite. + +### 5.1 Must Stub Now (v0.1) + +These interfaces are needed for the hand-authored Transit District to be expressible in generator-compatible terms. This validates the data model. + +| Stub | What it does | Why now | +|------|-------------|---------| +| `DistrictSkeleton` struct | Serializable district description | The v0.1 Transit District should be representable as a DistrictSkeleton. This validates Q-036 — if the hand-authored district can be expressed as generator output, the data structure is correct. | +| `ChunkData` struct | Per-chunk tile storage with edge contracts | Already partially exists for D-012 chunk loading. Needs edge contract fields added. | +| `BlockSkeleton` struct | Per-block zoning + chunk layout | Validates that the 2×2 block decomposition works for the Transit District's hand-authored social sites. | +| `SocialSitePlacement` struct | Template tag + block/chunk coordinates + NPC slots | Validates that D-025 social sites can be addressed within the spatial hierarchy. | +| `DistrictType` enum | Transit, Residential, Commercial, etc. | Needed for Sova station's 6-district model (D-093, station profile). | +| `AccessPoint` / `CorridorSpine` | Entry points and corridor network | Validates the access topology from D-093 (gate cluster → transition → terminal → bar). | + +**Effort estimate: ~3-4 developer-days** to define structs, serialize the Transit District as a DistrictSkeleton, and write validation tests. + +### 5.2 Stub at Block/Chunk Level (v0.1-v0.2) + +| Stub | What it does | Target | +|------|-------------|--------| +| `ChunkLayout` enum | Merge strategy per block | v0.1 — needed for Transit District block decomposition | +| `EdgeContract` struct | Connection points per chunk face | v0.2 — first generated chunks need this | +| `ZoningType` enum | Block-level land use classification | v0.2 — drives template selection | + +### 5.3 Generator Pipeline Stubs (v0.2+, Design Only Now) + +These are the pipeline stages themselves. v0.1 doesn't execute them, but the stage interfaces should be **designed** (not implemented) now so the pipeline architecture is validated. + +| Pipeline Stage | Input | Output | Implementation target | +|----------------|-------|--------|----------------------| +| Geography | World seed, system parameters | Planet/station type, basic terrain | v0.6+ | +| Infrastructure | Geography output, transport network | Station layout (district count, positions, connections) | v0.4+ | +| Zoning | Infrastructure, economic/political context | Per-block zoning assignments | v0.3+ | +| Block Planning | Zoning, social site library, population targets | BlockSkeletons with ChunkLayouts + edge contracts | v0.3+ | +| Chunk Fill | BlockSkeleton, edge contracts, template library | Tile data for each 64×64 chunk | v0.2 (first target) | +| NPC Population | Social site placements, population density, cultural profile | NPC generation (D-024 axes, role assignments) | v0.3+ | + +**Critical path for Q-037:** Chunk Fill is the first generator stage to implement (v0.2) because it's the most concrete — takes a planned block and fills tiles from templates. Everything above it can be hand-specified while Chunk Fill is developed and validated. + +### 5.4 Validation Strategy: Transit District as Generator Ground Truth + +**Recommendation:** Express the v0.1 Transit District (D-093) as a hand-authored `DistrictSkeleton` + hand-authored `ChunkData` for each of its 64 chunks. This serves as: + +1. **Schema validation** — if the skeleton can't express the Transit District, the schema is wrong +2. **Generator test fixture** — future generator output is compared against the hand-authored ground truth +3. **Content pipeline test** — the skeleton → rendered map pipeline is validated end-to-end with known-good data + +This is *not* requiring the Transit District to be "generated." It's requiring the generator's output format to be expressive enough to describe the Transit District. If it can describe the most complex hand-authored district, it can describe anything the generator produces. + +--- + +## 6. Technical Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Edge contract system produces tile discontinuities at chunk boundaries | HIGH | Comprehensive boundary tests; Transit District as test fixture validates edge alignment | +| Generator can't fill chunks within 500ms budget | MEDIUM | Template stamping (not WFC) for v0.2; WFC only if templates are insufficient | +| Multi-block structures create irregular block boundaries | MEDIUM | Reservation system claims blocks before fill; reserved blocks use custom layouts | +| District skeleton data model doesn't survive contact with diverse district types | MEDIUM | Validate against all 6 Sova district types + 3 planetary settlement types before locking | +| Cross-district structures (park spanning two districts) create coordination complexity | LOW | Cap at v0.6+; v0.1-0.5 districts are self-contained. `cross_district: bool` on reservations is the escape hatch. | +| Quarter system over-complicates chunk fill | LOW | Quarters are generation-side only; if they cause problems, fall back to free-form template placement within chunks | + +--- + +## 7. Summary of Hard Constraints + +1. **Chunk = 64×64 sim tiles.** Non-negotiable. Streaming atom. (D-094) +2. **Block = 2×2 chunks.** Generator planning unit. (D-094) +3. **District = 4×4 blocks = 64 chunks.** Template composition unit. (D-094) +4. **Hierarchy = 4 levels.** Quarters are fill rules, not hierarchy levels. +5. **Edge contracts at chunk boundaries.** Required for cross-chunk tile continuity. +6. **District skeleton must express D-025 social sites.** Generator arranges templates, doesn't invent new site types. +7. **Chunk fill budget: ~500ms.** Based on player walk speed and 3×3 loading grid. +8. **District generation must be local.** Required for D-012 borderless generation future. +9. **MessagePack serialization for all generator output.** Per D-020. +10. **Deterministic from seed.** Per D-010 principle 4. Same seed → same district → same tiles. + +--- + +*Tyre — Round 1 complete. Standing by for Round 2 cross-pollination.* diff --git a/docs/workshops/generator-architecture/tyre-round2.md b/docs/workshops/generator-architecture/tyre-round2.md new file mode 100644 index 000000000..23c3be9c6 --- /dev/null +++ b/docs/workshops/generator-architecture/tyre-round2.md @@ -0,0 +1,793 @@ +# Round 2: Tyre — Technical Pipeline with Two-Phase Generation and Edge Bleed + +**Workshop:** Generator Architecture (#562) +**Agent:** Tyre (Technical Architect) +**Date:** 2026-02-27 + +**Lead directive acknowledged:** This is NOT a detective game. The generator must support tycoon, dating sim, political drama, and investigation playstyles equally. The DistrictSkeleton and all spatial guarantees are playstyle-agnostic. The architecture bakes in *information asymmetry as a spatial property*, not investigation as a gameplay assumption. + +--- + +## 1. Two-Phase Generation Architecture + +The lead directive splits generation into two architecturally separate phases. *cracks knuckles* — this is actually elegant, because it maps cleanly onto two different computational profiles. + +### 1.1 Phase 1: World Prep (Background, Async) + +Runs on a spare CPU core while the player is playing. Produces the **skeleton layer** — everything above chunk fill. This is the "what goes where" pass. + +``` +┌──────────────────────────────────────────────────────────┐ +│ PHASE 1: WORLD PREP (background thread, ~50-500ms/district) │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ Master Seed │ +│ ↓ │ +│ System Generation (star type, worlds, stations) │ +│ ↓ │ +│ Society Profile per world (ingredients → parameters) │ +│ ↓ │ +│ District Skeletons per world (zoning, social sites, │ +│ access topology, NPC slots, reservations, │ +│ corridor spines, zone palettes) │ +│ ↓ │ +│ Block Planning per district (ChunkLayout, edge │ +│ contracts, era tags, quarter assignments) │ +│ ↓ │ +│ NPC Population per district (role assignment, │ +│ triangle seeding, entanglement marking) │ +│ ↓ │ +│ OUTPUT: PreparedDistrict (skeleton + block plans + │ +│ NPC roster — everything except tile data) │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +**Characteristics:** +- CPU-bound, no I/O. Pure deterministic computation from seed. +- Can run speculatively for districts the player hasn't visited yet. +- Output is small (~10-50 KB per district). All PreparedDistricts for a 300-world game fit in ~30-150 MB. +- No rendering dependency. No Godot interaction. Pure Rust. +- **Scheduling:** Prepare the player's home system at game start (blocking). Queue neighboring systems by gate distance. Prepare on-demand when the player books travel. + +**Timing budget:** Phase 1 for one district: ~50-500ms (dominated by NPC population generation). One full world (6 districts): ~300ms-3s. Entire 300-world galaxy: ~90-900s (1.5-15 minutes). At game start, only the home system is blocking (~2-3s); everything else runs in background. + +### 1.2 Phase 2: Local Area Gen (On-Demand, Interactive) + +Runs when the player enters a district for the first time, triggered by chunk loading. Produces **tile data** — the actual playable space. + +``` +┌──────────────────────────────────────────────────────────┐ +│ PHASE 2: LOCAL AREA GEN (on-demand, ~100-500ms/chunk) │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ PreparedDistrict (from Phase 1) │ +│ ↓ │ +│ Chunk Fill (per chunk, on entering loading radius) │ +│ - Read BlockSkeleton + edge contracts │ +│ - Select template from social site tag │ +│ - Place walls, floors, furniture, fixtures │ +│ - Apply zone palette + era materials │ +│ - Place NPC spawn points from roster │ +│ - Validate edge contracts against neighbors │ +│ ↓ │ +│ OUTPUT: ChunkData (64×64 tile array, ready to stream) │ +│ │ +│ Chunk Cache (LRU, persists to save file) │ +│ - Generated chunks cached in memory │ +│ - Written to save on save-game │ +│ - Loaded from save on load-game (skips re-gen) │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +**Characteristics:** +- Runs on the simulation thread (or a dedicated gen thread with result handoff). +- Template-based stamping — NOT full WFC. WFC is a future optimization if templates prove insufficient. +- Each chunk fill reads only its own BlockSkeleton + neighbor edge contracts. No global state dependency. +- **Idempotent from seed:** Same PreparedDistrict + same chunk coordinates → same ChunkData. Always. +- Once generated, chunks are cached and never regenerated (unless the save file is wiped). + +**Timing budget per chunk:** ~100-500ms. Player walk speed = 1 tile/200ms, crossing a chunk takes ~12.8s. New chunks enter the 3×3 loading grid every ~6-12s. Budget is generous. + +### 1.3 The Interface Between Phases + +The `PreparedDistrict` is the contract between Phase 1 and Phase 2. It is the only data structure that crosses the boundary. Phase 2 never calls Phase 1 functions. Phase 1 never produces tile data. + +```rust +/// The contract between Phase 1 (world prep) and Phase 2 (local gen). +/// Serializable, cacheable, deterministic from seed. +struct PreparedDistrict { + skeleton: DistrictSkeleton, // spatial plan (§2 below) + block_plans: [[BlockPlan; 4]; 4], // per-block fill instructions + npc_roster: NpcRoster, // generated NPCs with role assignments + seed_chain: SeedChain, // derived seeds for Phase 2 determinism +} + +struct BlockPlan { + skeleton: BlockSkeleton, // from Phase 1 + chunk_fills: [[ChunkFillSpec; 2]; 2], // per-chunk fill instructions + edge_contracts: BlockEdgeContracts, // connection points on all 4 faces +} + +struct ChunkFillSpec { + /// Template tag to instantiate (e.g., "logistics_hub_main_floor") + template_tag: String, + /// Quarter layout within this chunk + quarter_layout: QuarterLayout, + /// Derived seed for this specific chunk's procedural details + chunk_seed: u64, + /// Zone palette inherited from district + zone_id: ZoneId, + /// Era tag inherited from block + era: Era, + /// NPC spawn points assigned from roster + npc_spawns: Vec<NpcSpawnPoint>, + /// Access tier for this chunk's primary zone + access_tier: AccessTier, +} +``` + +--- + +## 2. Updated DistrictSkeleton with Edge Bleed + +The lead directive is clear: the 4×4 block grid must NOT be perceptible. Districts must bleed into each other at boundaries. + +### 2.1 The Edge Bleed Problem + +D-094 defines a district as 512×512 sim tiles (4×4 blocks). If two adjacent districts have hard boundaries — Gate Cluster ends at block (3,y) and Residential starts at block (0,y) — the player walks through a visual seam. That seam screams "procedural grid." + +### 2.2 Solution: Shared Boundary Blocks + +At district boundaries, adjacent districts share a **transition strip** — a row of blocks that belongs to neither district exclusively. These blocks blend the zone palettes, era tags, and building character of both districts. + +``` +District A District B +┌────┬────┬────┬────┐ ┌────┬────┬────┬────┐ +│ A │ A │ A │ A │ │ B │ B │ B │ B │ +├────┼────┼────┼────┤ ├────┼────┼────┼────┤ +│ A │ A │ A │ A │ │ B │ B │ B │ B │ +├────┼────┼────┼────┤ ├────┼────┼────┼────┤ +│ A │ A │ A │ A │ │ B │ B │ B │ B │ +├────┼────┼────┼────┤ ├────┼────┼────┼────┤ +│ A │ A │ Aₜ │ Aₜ │←─ SHARED ─→│ Bₜ │ Bₜ │ B │ B │ +└────┴────┴────┴────┘ └────┴────┴────┴────┘ + +Aₜ/Bₜ = transition blocks. Visually: A's palette → neutral → B's palette. +``` + +**How it works:** + +The outermost column/row of each district is designated as the **transition strip**. Transition blocks: +- Use a blended zone palette (weighted average of both districts' adjacent zone palettes) +- Can have mixed era tags (one chunk from District A's era, one from District B's) +- Use a specific "transition corridor" street type (per V-05: 6vt width, neutral industrial palette) +- Building footprints in transition blocks are smaller (no 2×2 full-merge buildings) to avoid buildings that feel like they belong to one district or the other +- Social sites are NOT placed in transition blocks — they are pass-through zones, not destinations + +### 2.3 Updated DistrictSkeleton Struct + +```rust +struct DistrictSkeleton { + district_id: DistrictId, + seed: u64, + district_type: DistrictType, + context: DistrictContext, + + /// The 4×4 block grid — interior blocks + blocks: [[BlockSkeleton; 4]; 4], + + /// NEW: Boundary descriptors for edge bleed + /// Each edge (N/S/E/W) describes what this district offers + /// to the shared transition strip with its neighbor + boundaries: DistrictBoundaries, + + social_sites: Vec<SocialSitePlacement>, + reservations: Vec<MultiBlockReservation>, + access_points: Vec<AccessPoint>, + corridors: Vec<CorridorSpine>, + z_levels: u8, + zone_palette: Vec<ZoneDefinition>, + + /// NEW: Society profile reference (serde-compatible, §4) + society_profile: SocietyProfileRef, + + /// NEW: Terrain type for non-urban districts (§5) + terrain: TerrainType, + + /// NEW: District complexity tier (§6) + complexity: ComplexityTier, +} + +struct DistrictBoundaries { + /// For each of the 4 edges, describe the transition interface + north: Option<BoundaryEdge>, + south: Option<BoundaryEdge>, + east: Option<BoundaryEdge>, + west: Option<BoundaryEdge>, +} + +struct BoundaryEdge { + /// Zone palette at this district's boundary edge + edge_palette: ZonePalette, + + /// Era tag at the boundary + edge_era: Era, + + /// Access points that open onto the boundary (doors, corridors) + /// These must align with the neighbor's corresponding access points + access_points: Vec<BoundaryAccessPoint>, + + /// Terrain type at the boundary (for non-urban transitions) + edge_terrain: TerrainType, + + /// Building density at boundary (always lower than interior) + edge_density: f32, // 0.0-1.0, typically 0.3-0.5 for transition zones +} + +struct BoundaryAccessPoint { + /// Position along the edge (0-3 for 4 blocks on this edge) + block_index: u8, + + /// Offset within the block (in chunks: 0 or 1) + chunk_offset: u8, + + /// Width in visual tiles + width_vt: u8, + + /// Access tier + access_tier: AccessTier, + + /// What kind of connection (street, corridor, service, restricted) + connection_type: ConnectionType, +} +``` + +### 2.4 Transition Block Generation + +Transition blocks are generated in Phase 1 as a **joint operation** between two adjacent PreparedDistricts. The algorithm: + +1. District A and District B are both Phase 1 complete. +2. For each shared edge, compute the transition strip: + - Read A's `boundaries.east` and B's `boundaries.west` (or whichever edge pair). + - Align access points: match A's boundary access points with B's. Where both districts offer a corridor, connect them. Where only one does, dead-end the other gracefully (service door, maintenance hatch). + - Blend zone palettes: transition blocks use `lerp(A.edge_palette, B.edge_palette, 0.5)` with rounding to nearest palette stop. + - Select era: use the older of the two boundary eras (transitions feel like infrastructure, not new construction). + - Generate transition block skeletons with the blended parameters. +3. Store transition blocks in a `TransitionStrip` struct shared between both PreparedDistricts. + +```rust +struct TransitionStrip { + /// Which two districts this strip connects + district_a: DistrictId, + district_b: DistrictId, + + /// Shared edge (from A's perspective) + edge: CardinalDirection, + + /// Transition blocks (1×4 strip = 4 blocks between the districts) + blocks: [TransitionBlock; 4], +} + +struct TransitionBlock { + /// Blended palette + palette: ZonePalette, + era: Era, + /// Simplified chunk layout (no large merges, mostly corridors) + chunks: [[ChunkFillSpec; 2]; 2], + /// Access points connecting to each district + connections_a: Vec<BoundaryAccessPoint>, + connections_b: Vec<BoundaryAccessPoint>, +} +``` + +**Memory cost:** 4 transition blocks per shared edge × ~1 KB each = ~4 KB per edge. A station with 6 districts has ~10 shared edges = ~40 KB of transition data. Trivial. + +**Visual result:** Walking from the Terminal district into the Residential Core, the player crosses 2-3 blocks of gradual transition — neutral corridor widening, palette shifting, era mixing, building scale changing. No seam. No grid visible. + +--- + +## 3. Seed Propagation — Single Master Seed with Deterministic Derivation + +The lead says "seeds are solved." Good. Here's the architecture. + +### 3.1 Seed Derivation Tree + +One master seed. Everything else is deterministically derived. No per-stage seeds as independent parameters. + +```rust +/// Single master seed → everything. +struct SeedChain { + master: u64, +} + +impl SeedChain { + /// Derive a sub-seed for a specific purpose. + /// Uses a keyed hash: blake3(master || domain_tag || index) + fn derive(&self, domain: &str, index: u64) -> u64 { + let mut hasher = blake3::Hasher::new(); + hasher.update(&self.master.to_le_bytes()); + hasher.update(domain.as_bytes()); + hasher.update(&index.to_le_bytes()); + let hash = hasher.finalize(); + u64::from_le_bytes(hash.as_bytes()[..8].try_into().unwrap()) + } + + fn system_seed(&self, system_id: u64) -> u64 { + self.derive("system", system_id) + } + + fn district_seed(&self, system_id: u64, district_id: u64) -> u64 { + self.derive("district", system_id * 10000 + district_id) + } + + fn npc_seed(&self, district_seed: u64, npc_index: u64) -> u64 { + self.derive("npc", district_seed.wrapping_mul(1000) + npc_index) + } + + fn chunk_seed(&self, district_seed: u64, chunk_x: u64, chunk_y: u64) -> u64 { + self.derive("chunk", district_seed ^ (chunk_x << 16) ^ chunk_y) + } +} +``` + +### 3.2 Answering Nigel's Question + +**"Same seed, different character selection — same world or different world?"** + +**Same world.** The master seed determines the physical world, NPC roster, triangle configurations, entanglement pattern — everything generated. Character selection is a **filter**, not a world-generation input. Both characters exist in the same generated world. The player picks which lens to view it through. + +This is architecturally correct per D-010 principle 3: "no baking player identity into the game loop." The simulation doesn't know which character is player-controlled. Character selection happens at the session layer, not the generation layer. + +**Consequence:** Two players with the same seed but different character choices play in an *identical* world. Their experiences differ because information boundaries (D-010 principle 2) filter what each character can see, access, and know. This is exactly the D-027 "two keyholes on the same world" promise. + +### 3.3 Seed-State Artifact (Q-030) + +The seed state is a single file recording all derivation inputs: + +```yaml +# seed-state.yaml — complete reproduction record +master_seed: 0xA7B3F1D2E5C84096 +character: smuggler # session layer, not generation layer +tier1_module_draws: [smuggling_ring, corporate_espionage] # pool draws from master seed +home_system: krenn +home_district: transit +# Everything else is deterministically derivable from master_seed. +# This file exists for debugging and replay, not as a generation input. +``` + +--- + +## 4. Society Profile as Serde Schema (OQ-5) + +Miri asks: can the content pipeline consume the society profile YAML as a serde-compatible schema? + +**Yes.** Feasible. Not even challenging. Here's what the Rust struct looks like: + +```rust +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SocietyProfile { + heritage: HeritageBlend, + settlement_motivation: Option<SettlementMotivation>, + economic_function: EconomicFunction, + economic_pressure: Vec<EconomicPressure>, // 0-2 items + drift_stage: DriftStage, + faction_presence: FactionPresence, + philosophical_alignment: Option<PhilosophicalAlignment>, + meridian_coverage: MeridianCoverage, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct HeritageBlend { + /// 1-3 roots with blend weights summing to 1.0 + roots: Vec<HeritageEntry>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct HeritageEntry { + root: HeritageRoot, + weight: f32, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +enum HeritageRoot { + Frost, Tide, Iron, Spice, Jade, + Dust, Vine, Salt, Stone, Arc, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum DriftStage { + Pioneer, // 0-50yr + Crystallizing, // 50-150yr + Mature, // 150-300yr + Ancient, // 300+yr +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct FactionPresence { + commission: PresenceTier, + concord: PresenceTier, + syndic: PresenceTier, + independent: PresenceTier, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum PresenceTier { + Comprehensive, Standard, Intermittent, Absent, +} + +// ... remaining enums follow the same pattern +``` + +**serde_yaml** handles this out of the box. Miri's YAML format maps 1:1 to Rust structs. NULL values → `Option<T>` with serde default. Blend weights → `Vec<HeritageEntry>` with a validation pass to ensure sum ≈ 1.0. + +**Validation:** Add a `validate()` method that checks: +- Heritage weights sum to 1.0 (±0.01 tolerance) +- At least 1 heritage root +- At most 3 heritage roots +- Economic pressure has 0-2 entries +- No contradictory faction presence (e.g., `commission: Comprehensive` + `independent: SystemWide`) + +**Integration:** Society profiles can be: +1. Hand-authored in YAML (for specific systems like Krenn) +2. Generated from seed (for the other 299 systems) +3. Loaded via serde_yaml and passed to the generation pipeline + +**Effort estimate:** ~1 developer-day to define all enum types and validation. The serde derive macros do the rest. + +--- + +## 5. Era Fields in Chunk Data (OQ-6) + +Miri asks: does the chunk data structure have era fields? + +**Yes. At the block level, inherited by chunks.** + +```rust +struct BlockSkeleton { + position: (u8, u8), + zoning: ZoningType, + reservation: Option<ReservationId>, + chunk_layout: ChunkLayout, + hosted_sites: Vec<SocialSiteId>, + + /// NEW: Construction era for this block + era: Era, + + /// NEW: Era modifications (retrofits, additions) + /// A block can have a base era + modification overlays + era_modifications: Vec<EraModification>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum Era { + /// Original construction. Lowest Meridian coverage. + /// Maintenance corridors, foundation infrastructure. + Era1, + + /// First major retrofit/expansion. Mixed coverage. + /// Operational spaces, working infrastructure. + Era2, + + /// Recent construction. Highest coverage. + /// Institutional, commercial, modern residential. + Era3, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct EraModification { + /// Which era this modification represents + era: Era, + + /// What fraction of the block shows this modification (0.0-1.0) + coverage: f32, + + /// Type of modification + mod_type: ModificationType, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum ModificationType { + /// Surface-mounted conduits, junction boxes (Era 2 on Era 1) + SurfaceRetrofit, + /// New partition walls, converted spaces (Era 3 on Era 1/2) + InternalConversion, + /// Extension/addition changing building footprint + StructuralAddition, + /// Commission-grade infrastructure upgrade + InstitutionalUpgrade, +} +``` + +**How it flows:** +1. Phase 1 assigns `era` per block based on district context + z-level + historical events. +2. Phase 1 assigns `era_modifications` for blocks that have been retrofitted. +3. Phase 2 (chunk fill) reads the block's era + modifications and selects materials accordingly. +4. Araminta's visual rules apply: base palette from era, modifications as overlay elements. + +**Z-level correlation (from D-093):** The default station pattern is z=0 → Era 1, z=1 → Era 2, z=2 → Era 3. But this isn't mandatory — a recently rebuilt ground level could be Era 3, with an old observation deck as Era 1. The generator decides per-block, not per-z-level. + +--- + +## 6. Population/Zoning Ordering (OQ-1) + +Gestalt raises: NPC secrets must have plausible staging grounds. Population can't be assigned before spaces exist. But spaces need population targets to be correctly sized. + +**Implementation cost of the feedback loop: LOW.** Here's why. + +### 6.1 The Two-Pass Solution + +This isn't a feedback loop — it's a two-pass pipeline where each pass produces a different artifact: + +**Pass 1 (in Phase 1, district skeleton stage):** +- Zoning assigns block types. +- Population **targets** are set from capacity formulas (population density × block count × block type multiplier). +- NPC **role slots** are allocated to social sites (e.g., "this logistics hub needs 1 supervisor, 3 dock workers, 2 customs handlers"). +- Triangle **templates** are selected (e.g., "workplace rivalry triangle in logistics hub" + "social tension triangle across bar and logistics hub"). +- Secret **type requirements** are checked: "this triangle requires a restricted-access staging ground" → verify at least one block has `access_tier: restricted`. If not, add one. + +**Pass 2 (in Phase 1, NPC population stage):** +- NPC 10-axis generation fills the role slots with concrete NPCs. +- Secrets are assigned to NPCs with spatial anchoring to specific blocks/chunks. +- Entanglement marking (which NPCs are in the 20%) is applied. +- The NPC roster is complete. + +**Key insight:** Pass 1 checks *spatial prerequisites*. It doesn't generate NPCs — it verifies that the spaces NPC secrets will need exist in the skeleton. If a triangle template requires a restricted zone and none exists, the skeleton adjusts its zoning (adds a restricted block) before NPC generation runs. This is a validation-and-adjust step, not a true feedback loop. + +**Implementation cost:** One `validate_spatial_prerequisites()` function that runs after zoning, before NPC population. Checks ~10 spatial requirements (each gameplay guarantee from Gestalt's Round 1) against the skeleton. Adjusts zoning for any unmet requirement. ~200 lines of Rust. Half a developer-day. + +### 6.2 Why This Isn't Expensive + +The prerequisites are finite and small: +- At least 1 block with `access_tier: Restricted` (for secrets requiring private space) +- At least 1 block with `meridian_coverage: Degraded` (for grey economy activity) +- At least 1 social site with `access_tier: Public` (for social manipulation) +- At least 1 social site with `access_tier: Insider` (for asymmetric access) +- At least 1 corridor spine connecting transit node to social sites (for routine observation) + +These are Gestalt's guarantees expressed as spatial validators. The zoning pass produces them naturally 95% of the time. The validator catches edge cases and adjusts the remaining 5%. + +--- + +## 7. Non-Urban Terrain: Farmland, Wilderness, Ocean, Secluded Towns + +The lead directive pushes beyond population hubs. *Let me be honest about what this means technically.* + +### 7.1 What Changes + +Non-urban terrain changes **chunk fill content**, not the hierarchy structure. Chunks are still 64×64 sim tiles. Blocks are still 2×2 chunks. Districts are still 4×4 blocks. The spatial hierarchy is terrain-agnostic. + +What changes: + +| Property | Urban | Non-Urban | +|----------|-------|-----------| +| Fill density | 60-100% of quarters filled with structures | 0-20% filled; rest is terrain | +| Template type | Buildings, corridors, rooms | Terrain features (fields, trees, water, paths) | +| NPC density | 30-80 per district | 0-10 per district | +| Social sites | 3-8 per district | 0-2 per district | +| Edge contracts | Door/corridor connections | Path/road connections | +| LOS anchors | Walls, pillars, furniture | Trees, terrain elevation, fences, hedgerows | +| Zone palette | Architectural materials | Natural materials (soil, grass, water, rock) | + +### 7.2 TerrainType Enum + +```rust +#[derive(Serialize, Deserialize, Clone, Debug)] +enum TerrainType { + /// Station interior (current default) + Station, + + /// Urban settlement (planet-side city) + Urban, + + /// Agricultural (farmland, orchards, greenhouses) + Agricultural, + + /// Wilderness (forest, grassland, desert, tundra) + Wilderness { biome: Biome }, + + /// Water (ocean, lake, river delta) + Water { water_type: WaterType }, + + /// Transitional (urban edge, suburbs, outskirts) + Transitional, + + /// Orbital (small installation, different geometry rules) + Orbital, +} +``` + +### 7.3 How Non-Urban Chunks Fill + +Non-urban chunk fill uses terrain templates instead of building templates: + +- **Agricultural:** Grid of field plots (each plot = 1-2 quarters), irrigation channels as corridors, farmhouse/barn as the 1-2 buildings per district. Edge contracts carry road/path connections. A farm district is mostly open space with sparse LOS anchors (fences, crop height variation, equipment sheds). + +- **Wilderness:** Procedural terrain with natural LOS blockers (trees, rock formations, elevation). Paths replace corridors. No buildings unless the district has a `SecludedSettlement` social site. Edge contracts carry trail connections. + +- **Water:** Mostly impassable tiles. Docks/jetties as narrow accessible strips. Boats as mobile platforms. Edge contracts carry dock access points. + +- **Transitional:** Sparse urban. Wide roads, scattered buildings, open lots. The "suburb" between a city district and farmland. This is where edge bleed naturally produces a transition from urban density to rural openness. + +### 7.4 What Stays the Same + +- **Chunk size:** 64×64 sim tiles. Still the streaming atom. A field is just a chunk full of crop tiles instead of floor tiles. +- **Block planning:** Still 2×2 chunks. The "block" in farmland means "which field plot goes where" instead of "which building footprint goes where." +- **District skeleton:** Still describes what's in the district. A wilderness district skeleton has fewer social sites (maybe 1 — a ranger station or hermit cabin) and more terrain descriptors. +- **Edge contracts:** Still define how chunks connect at boundaries. Roads connect instead of corridors. +- **Shadowcasting:** Still works. Trees and terrain features occlude LOS just like walls. + +### 7.5 Insignificant Places + +The lead directive explicitly requires "boring" districts — low-complexity, low-NPC, pass-through zones. + +```rust +#[derive(Serialize, Deserialize, Clone, Debug)] +enum ComplexityTier { + /// Full gameplay district — multiple social sites, rich NPC population, + /// all gameplay guarantees met. (Transit District, Residential Core) + Full, + + /// Moderate — 1-2 social sites, moderate NPC population, partial + /// gameplay guarantees. (Commercial Quarter, Industrial Sector) + Moderate, + + /// Minimal — 0-1 social sites, sparse NPCs, pass-through zone. + /// No gameplay guarantees required. (Farmland, wilderness, transit corridor) + Minimal, + + /// Empty — no social sites, no NPCs. Pure terrain. + /// (Open water, deep wilderness, uninhabited terrain) + Empty, +} +``` + +**Gameplay guarantees (Gestalt's 7 from Round 1) only apply to `Full` complexity districts.** A farmland district doesn't need a surveillance chokepoint or three investigation paths. It needs to exist, be traversable, and feel appropriate to its terrain type. + +**Why this matters for performance:** Minimal/Empty districts are trivially cheap. Their skeletons are tiny (~500 bytes). Their chunk fill is fast (terrain stamping, no NPC placement, no social site layout). The generator can produce hundreds of these as background filler for a planet-side world without meaningful CPU cost. + +--- + +## 8. Addressing Remaining Open Questions + +### 8.1 Quarter Fill Social Consequences (OQ-3) + +Ozzie asks: does the quarter fill type have downstream social consequences? + +**Yes, but through an indirect mechanism.** The quarter fill type is selected based on society profile + economic tier, which are the same parameters that drive NPC generation. A "market stall" quarter appears in districts with `economic_function: Mixed` or `economic_pressure: [tight-margin]`, which also produces NPCs with specific behavioral patterns (transaction-oriented trust models, informal economy participation). + +The quarter fill doesn't *cause* NPC behavior. Both the quarter fill and the NPC behavior are *caused by the same upstream parameters*. The player sees correlation (market stalls → certain NPC types) and reads it as causation. That's architecturally correct — the relationship is real, just indirect. + +**Implementation:** The quarter fill tag feeds into the NPC roster's `spawn_location_preference` field. NPCs generated with "informal economy" traits prefer to spawn near market stall quarters. This creates the spatial correlation Ozzie wants without a direct quarter → NPC dependency. + +### 8.2 Historical Palimpsest (OQ-4) + +Ozzie asks: when the generator produces an L-shaped building, does it record WHY? + +**The generator records the causal chain, but the player discovers the reason through gameplay, not data inspection.** + +The `EraModification` system (§5 above) encodes the cause: an L-shaped building has `mod_type: StructuralAddition` with an era tag indicating when the addition was built. The NPC roster can include NPCs who remember the change ("They added that wing after the dock expansion. Took our courtyard."). + +What the generator does NOT do: generate a text explanation for every spatial anomaly. The anomalies come from the era/modification system; the explanations come from the NPC knowledge system and environmental text. This is the correct separation of concerns — the generator builds the space; the content systems make it legible. + +### 8.3 Empty Quarter Taxonomy Reconciliation (OQ-8) + +Nigel's categories (informal economy, settlement, economic stress, faction presence) and Araminta's categories (plaza, service alley, courtyard, vehicle staging, structural gap) are **orthogonal axes, not conflicting taxonomies.** + +Araminta's types describe **physical form** (what the space looks like). Nigel's describe **social function** (what the space means). A market stall (Nigel: informal economy) is physically a **service alley** (Araminta) with vendor cart furniture. A personal shrine (Nigel: settlement indicator) is physically a **courtyard** (Araminta) with shrine furniture. + +```rust +struct QuarterFill { + /// Physical form (Araminta's taxonomy) + form: QuarterForm, + + /// Social function (Nigel's taxonomy) + function: QuarterFunction, + + /// Furniture/object set selected from form × function + furnishing_tag: String, +} + +enum QuarterForm { + Plaza, ServiceAlley, Courtyard, VehicleStaging, StructuralGap, +} + +enum QuarterFunction { + InformalEconomy, Settlement, EconomicStress, FactionPresence, Neutral, +} +``` + +The `form × function` matrix produces the furniture selection. Not all combinations are valid (no `VehicleStaging × Settlement` — cargo docks don't become shrines). The generator maintains a validity table. + +--- + +## 9. Updated Pipeline Summary + +``` +PHASE 1: WORLD PREP (background, async) +═══════════════════════════════════════ + +Master Seed + ↓ +System Generation ─────── derives: system_seed + ↓ +Society Profile ────────── derives: society_seed + ↓ output: SocietyProfile (serde YAML) + ↓ +District Skeletons ─────── derives: district_seed per district + ├── Zoning (block types, access tiers) + ├── Spatial prerequisite validation (Gestalt's 7 guarantees, + │ only for Full complexity districts) + ├── Social site placement (D-025 template selection + positioning) + ├── Multi-block reservations + ├── Corridor spines + access points + ├── Zone palette assignment + └── Boundary descriptors (for edge bleed) + ↓ +Block Planning ──────────── per district + ├── ChunkLayout selection (merge strategy) + ├── Era assignment + modifications + ├── Edge contract computation + └── Quarter layout (form × function) + ↓ +NPC Population ──────────── per district + ├── Role slot filling (10-axis generation) + ├── Triangle configuration + ├── Entanglement marking + └── Spawn location preferences + ↓ +Transition Strip Gen ────── per shared district edge + ├── Palette blending + ├── Access point alignment + └── Transition block skeletons + ↓ +OUTPUT: PreparedDistrict + TransitionStrips + + +PHASE 2: LOCAL AREA GEN (on-demand, per chunk) +═══════════════════════════════════════════════ + +PreparedDistrict + ↓ +Chunk enters loading radius + ↓ +Chunk Fill ──────────────── derives: chunk_seed + ├── Read BlockPlan + edge contracts + ├── Select/stamp template from social site tag + ├── Place terrain (non-urban) or architecture (urban) + ├── Apply zone palette + era materials + ├── Place furniture from form × function + ├── Place NPC spawn points + ├── Validate edge contracts against loaded neighbors + └── Apply LOS anchor placement rules + ↓ +OUTPUT: ChunkData (cached, saved) +``` + +--- + +## 10. Cost Summary + +| Component | Effort | Target | +|-----------|--------|--------| +| SeedChain + derivation | 0.5 dev-days | v0.2 | +| SocietyProfile serde schema | 1 dev-day | v0.2 | +| DistrictSkeleton struct (updated, with boundaries) | 2 dev-days | v0.1 stub, v0.2 impl | +| BlockPlan + ChunkFillSpec structs | 1 dev-day | v0.1 stub | +| TransitionStrip generation | 2 dev-days | v0.3 | +| TerrainType + non-urban chunk fill | 3 dev-days | v0.4+ | +| ComplexityTier + minimal/empty district gen | 1 dev-day | v0.3 | +| Spatial prerequisite validator | 0.5 dev-days | v0.3 | +| Transit District as DistrictSkeleton (validation) | 3 dev-days | v0.1 | +| Phase 1 background thread + scheduling | 2 dev-days | v0.3 | +| Phase 2 chunk fill (template stamping) | 5 dev-days | v0.2 | +| **Total** | **~21 dev-days** | **spread v0.1-0.4** | + +Feasible. Challenging but doable. The critical path item is the Phase 2 chunk fill (~5 dev-days in v0.2) because it's the first thing that produces visible tiles. Everything else builds toward it or extends from it. + +--- + +*Tyre — Round 2 complete. The two-phase split is clean. Edge bleed is solved at the data structure level. Non-urban terrain fits the same hierarchy. Seeds are trivially solved. Standing by for Round 3 convergence.* diff --git a/docs/workshops/generator-architecture/tyre-round3.md b/docs/workshops/generator-architecture/tyre-round3.md new file mode 100644 index 000000000..2d5874e1f --- /dev/null +++ b/docs/workshops/generator-architecture/tyre-round3.md @@ -0,0 +1,1119 @@ +# Round 3: Tyre — Canonical DistrictSkeleton, Mobile Chunks, Grid Breathing, Vertical Scale + +**Workshop:** Generator Architecture (#562) +**Agent:** Tyre (Technical Architect) +**Date:** 2026-02-27 + +**Round 3 scope:** Convergence. Seven directives from lead. This is where the architecture hardens. + +--- + +## 1. Grid Breathing — BOTH Modes + +Ozzie's been asking for this since Round 1, and she's right. The question: can the 4×4 block grid accommodate non-rectilinear layouts? Can streets curve? Can two adjacent districts have different orientations? + +*cracks knuckles* — Let me be honest. The D-094 hierarchy (district = 4×4 blocks, block = 2×2 chunks, chunk = 64×64 sim tiles) defines **data sizes**, not geometry. A "block" is a 128×128 sim tile allocation unit. Nothing in D-094 says those allocation units must tile in a perfect Cartesian grid with perpendicular streets. + +### 1.1 Two Layout Modes + +The architecture supports **both** rectilinear and organic layouts through a `DistrictLayoutMode` that governs how blocks are placed within the district's 512×512 sim tile footprint. + +```rust +#[derive(Serialize, Deserialize, Clone, Debug)] +enum DistrictLayoutMode { + /// Standard grid: blocks tile in a 4×4 Cartesian arrangement. + /// Streets are perpendicular. Blocks are axis-aligned. + /// Use for: station interiors, planned cities, institutional zones. + Grid, + + /// Organic: blocks are placed with position + rotation offsets. + /// Streets follow terrain contours, historical paths, or natural features. + /// Use for: planet-side settlements, old-quarter stations, wilderness. + Organic { + /// Per-block placement offsets and rotations + placements: [[BlockPlacement; 4]; 4], + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct BlockPlacement { + /// Offset from grid-aligned position, in sim tiles. + /// (0, 0) = perfect grid alignment. + /// Max offset: ±16 sim tiles (quarter-chunk) in each axis. + offset: (i16, i16), + + /// Rotation from grid-aligned orientation, in 15° increments. + /// 0 = aligned with grid. Values: 0, 1 (15°), 2 (30°), 3 (45°). + /// Maximum 45° rotation — steeper angles break tile-based pathfinding. + rotation_steps: u8, + + /// Street width multiplier for streets adjacent to this block. + /// 1.0 = standard (4vt). Range: 0.75–2.0. + street_width_factor: f32, +} +``` + +### 1.2 How Organic Mode Works + +In `Organic` mode: + +1. **Blocks shift and rotate.** Each block has a position offset (max ±16 sim tiles) and a rotation (max 45°, in 15° increments). This produces streets that aren't straight — they follow the gaps between misaligned blocks. + +2. **Streets are the negative space.** In grid mode, streets are a fixed-width band between blocks. In organic mode, streets are whatever space remains between the shifted/rotated block footprints. This naturally produces variable-width streets, curved paths, and irregular intersections. + +3. **Edge contracts still work.** The edge contract system (from Round 2) defines connection points per chunk face. When blocks are rotated, edge contracts rotate with them. Two adjacent chunks know their relative orientation and align connection points accordingly. The connection logic becomes: "my north face at 15° needs to connect to your south face at 0°" — which resolves to a specific set of tile positions on the shared boundary. + +4. **The 45° rotation cap is hard.** Beyond 45°, tile-based movement on the 0.5m sim grid produces unacceptable pathfinding artifacts — diagonal corridors narrower than 1 sim tile, inaccessible corner cells, ambiguous wall ownership. 45° is the safe maximum for a tile-based engine. + +5. **Chunks within a rotated block stay axis-aligned internally.** The rotation applies to the block's position and orientation within the district. The chunk's internal 64×64 tile grid remains axis-aligned — walls, floors, and furniture are placed on the sim tile grid as normal. What changes is which tiles are "exterior" (facing the rotated street) versus "interior." + +### 1.3 What This Produces Spatially + +**Grid mode (stations, planned cities):** +``` +┌────┬────┬────┬────┐ +│ │ │ │ │ +├────┼────┼────┼────┤ +│ │ │ │ │ ← perpendicular streets +├────┼────┼────┼────┤ axis-aligned blocks +│ │ │ │ │ +├────┼────┼────┼────┤ +│ │ │ │ │ +└────┴────┴────┴────┘ +``` + +**Organic mode (old quarter, planet-side settlement):** +``` +┌────┐ ╱────╲ +│ │╱╱ ╲╲ +├────╱ ┌─────┐╲ +│ ╱ │ 15° ││ ← blocks offset and rotated +│ ╱ ┌──┤ ├┘ streets fill the gaps +│ ╱ │ └─────┘ variable-width, curved +├╱ │ ┌──────┐ +│ └─────┤ │ +└───────────┴──────┘ +``` + +**Mixed mode within a district:** Not directly — a single district has one layout mode. BUT: adjacent districts can have different modes. A planned station district (Grid) can neighbor an old-quarter settlement (Organic), and the transition strip between them handles the orientation mismatch. This is exactly what Ozzie asked for: "two adjacent districts can have different orientations." + +### 1.4 When Each Mode Is Used + +| Setting | Mode | Reason | +|---------|------|--------| +| Station interior | Grid | Stations are constructed, engineered, planned | +| Planned city district | Grid | Modern urban planning = grid | +| Old-quarter / historic district | Organic | Built over centuries, never replanned | +| Planet-side settlement | Organic | Grew around geography, not on a blueprint | +| Agricultural district | Organic (minimal rotation) | Fields follow terrain contours | +| Wilderness | Organic (high rotation) | Paths follow geography, no grid at all | +| Transitional (suburb) | Grid (with offset only, no rotation) | Semi-planned, irregular edges | + +The generator selects layout mode from `TerrainType` + `DriftStage`. Pioneer settlements are Grid (freshly planned). Ancient settlements that started as Pioneer may be Grid at center, Organic at edges — the city grew beyond its plan. + +### 1.5 Performance and Complexity Cost + +**Implementation effort:** ~3 dev-days for organic mode. +- 1 day: `BlockPlacement` struct + block footprint calculation with offsets and rotation +- 1 day: rotated edge contract resolution (the hard part — computing shared boundaries between non-axis-aligned blocks) +- 1 day: street-as-negative-space chunk fill for organic gaps + +**Runtime cost:** Negligible. Block placement is Phase 1 (computed once). The rotation math is a few matrix multiplies per block — 16 blocks = 16 multiplies. Not measurable. + +**Risk:** Rotated edge contracts are the complexity hotspot. If two blocks are rotated by different amounts, the shared boundary is not a clean 64-tile line — it's a diagonal strip. The chunk fill needs to handle this diagonal interface. This is **challenging but doable** — essentially a variant of the same rasterization problem that angled walls already solve in the tile engine. + +### 1.6 Answering Ozzie Directly + +> "Tell me the grid can breathe." + +It can breathe. Organic mode produces districts where no two blocks are axis-aligned the same way, streets follow the gaps between shifted buildings, and the result looks like a settlement that grew rather than one that was stamped. + +> "Tell me two adjacent districts can have different orientations." + +They can. District A = Grid, District B = Organic. The transition strip handles the mismatch. + +> "Tell me a street can curve because the geography required it." + +It can. In organic mode, streets are negative space between rotated blocks. They curve because the blocks curve. And the blocks curve because the terrain, or the history, or the drift stage said they should. + +What the grid CANNOT do: produce a smoothly curving boulevard. The minimum rotation step is 15°, and the minimum block width is 128 sim tiles (64m). This produces angular organic layouts, not flowing curves. For flowing curves, Araminta's seven anti-grid visual techniques (diagonal connectors, setback variation, overhead extensions, infrastructure routing, light territories, vegetation overflow, street width variation) remain the necessary visual layer. The architecture provides the skeleton; the visual grammar smooths the edges. + +--- + +## 2. Entity-Carried Chunks — Mobile Interiors + +Trains, ships, spaceships, traincars. The lead says: CORE requirement, not deferred. + +### 2.1 The Concept + +A mobile chunk is a chunk (64×64 sim tiles) that is attached to an entity rather than a fixed world coordinate. The entity moves; the chunk moves with it. + +**Examples:** +- A train car: 1 chunk (maybe smaller — 32×16 interior). Moves along a rail. +- A ship cabin: 1 chunk. Moves on water. +- A spaceship interior: 1–4 chunks (depending on vessel size). Moves between systems. + +### 2.2 Data Model + +```rust +/// A chunk attached to a mobile entity instead of fixed coordinates. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct MobileChunk { + /// Entity this chunk is attached to + entity_id: EntityId, + + /// The chunk data itself — same format as static chunks + data: ChunkData, + + /// Interior dimensions in sim tiles (may be smaller than 64×64) + interior_size: (u16, u16), + + /// Current world position of the entity's anchor point + world_position: WorldPosition, + + /// Movement state + movement: MobileMovementState, + + /// Connection points to the outside world (doors, airlocks, gangways) + /// Active only when the entity is docked/stopped + access_points: Vec<MobileAccessPoint>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum MobileMovementState { + /// Docked at a fixed position — accessible from the world + Docked { + dock_position: WorldPosition, + connected_chunk: Option<ChunkCoord>, // which static chunk the door connects to + }, + + /// In transit — interior accessible, exterior is not the world + InTransit { + route: RouteId, + progress: f32, // 0.0–1.0 along route + speed: f32, // sim tiles per tick + }, + + /// Between systems — only the interior exists + InterSystem { + origin: SystemId, + destination: SystemId, + progress: f32, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct MobileAccessPoint { + /// Position within the mobile chunk (interior coordinates) + interior_position: (u16, u16), + + /// Direction the access point faces (relative to entity heading) + facing: CardinalDirection, + + /// What kind of connection (gangway, airlock, cargo door) + connection_type: MobileConnectionType, + + /// Is this access point currently active (entity is docked and aligned)? + active: bool, +} +``` + +### 2.3 How It Integrates with D-012 Streaming + +The chunk streaming model (D-012) loads chunks within a radius of the player. For mobile chunks: + +**When the player is OUTSIDE the mobile entity:** +- The mobile entity is a sprite on the world map (a train on a track, a ship on water). +- Its interior chunk is NOT loaded — the player can't see inside. +- If the entity is docked, its access points connect to adjacent static chunks. The player sees a door/gangway and can enter. + +**When the player is INSIDE the mobile entity:** +- The mobile chunk IS loaded (it's within the player's radius — the player is in it). +- The surrounding 3×3 chunk grid still loads... but what's loaded depends on `MobileMovementState`: + - **Docked:** The grid loads the dock's static chunks. The player sees the dock through windows/doors. + - **InTransit:** The grid loads a scrolling exterior view. For a train: the landscape tiles outside the windows update based on `route` + `progress`. For a ship: water tiles. This is a **visual effect only** — the sim doesn't load terrain tiles for the exterior during transit. The player sees a moving background through window tiles. + - **InterSystem:** Only the mobile chunk is loaded. Exterior is void/space. This is the most constrained environment in the game — a sealed interior with no exit until arrival. + +**Key constraint:** Only one mobile chunk is loaded at a time per player. No nested mobile chunks (a train carrying a car carrying a person). This is a v0.1 limitation that could be relaxed later but isn't worth the architectural complexity now. + +### 2.4 Interior Sizing + +Not all mobile interiors need a full 64×64 chunk: + +| Vehicle type | Interior size | Chunk usage | +|-------------|---------------|-------------| +| Small boat / shuttle | 16×8 sim tiles (8m × 4m) | Partial chunk (padded to 64×64 with void) | +| Train car | 32×8 sim tiles (16m × 4m) | Partial chunk | +| Large ship cabin | 32×32 sim tiles (16m × 16m) | Half chunk | +| Spaceship (small) | 64×64 sim tiles | Full chunk | +| Spaceship (large) | 2×2 chunks (128×128) | Multiple chunks — treated as a mobile "block" | + +Partial chunks are standard 64×64 allocations where only a portion contains interior tiles. The rest is void/impassable. This avoids needing a variable-size chunk type. + +### 2.5 Generation + +Mobile chunk interiors are generated like static social site chunks: +- Phase 1 assigns the vehicle type, interior template tag, and NPC roster (crew/passengers). +- Phase 2 fills the chunk from the template when the player first enters. +- The filled chunk is cached — entering the same train car later loads from cache. + +**Key difference from static chunks:** Mobile chunks can be **instanced**. All train cars of the same type on the same route share a template. The seed varies by entity ID, producing cosmetic variation (different cargo, different graffiti, different maintenance state) on the same floor plan. + +### 2.6 Loading and Unloading During Movement + +When a docked entity begins transit: +1. The access point deactivates (`active: false`). +2. The static chunks adjacent to the dock remain loaded (other entities or NPCs may be there). +3. The mobile chunk transitions to `InTransit` state. +4. The exterior visual buffer switches from "static world tiles" to "scrolling route tiles." +5. The player's chunk grid recenters on the mobile chunk. Static world chunks unload as they leave the grid. + +When a transit entity docks: +1. The entity reaches `progress: 1.0` on its route. +2. The dock's static chunks enter the loading grid. +3. Access points activate. +4. The player can exit normally. + +**NPC behavior during transit:** NPCs on the mobile chunk continue their simulation. They have routines within the vehicle interior (crew patrols, passengers read/sleep/talk). The D-031 day-phase system applies — NPCs on a ship have meal times, rest times, watch times. Miri's `bounded_mobile` social site tag governs the trust-building acceleration and privacy-level reduction for vessel interiors. + +### 2.7 Cost and Risk + +| Component | Effort | Risk | +|-----------|--------|------| +| MobileChunk data structure | 1 dev-day | Low | +| Docked state + access point connection | 2 dev-days | Medium — aligning mobile and static chunk doors | +| InTransit exterior visual scroll | 3 dev-days | Medium — scrolling tile buffer is new rendering code | +| InterSystem void state | 0.5 dev-days | Low — simplest case | +| Mobile chunk template library | 2 dev-days (3-4 templates) | Low | +| NPC routines on mobile chunks | 1 dev-day | Low — D-031 already handles bounded spaces | +| **Total** | **~9.5 dev-days** | **Medium overall** | + +**Target milestone:** v0.3. Mobile chunks require the base streaming model (v0.1–v0.2) to be working first. The scrolling exterior visual is the highest-risk component — it's new rendering code that doesn't exist in the static chunk model. + +**Deferred:** Multi-chunk vehicles (large spaceships). These are v0.5+ — treat as a mobile "block" with 2×2 mobile chunks and internal chunk boundaries. Same principle, more plumbing. + +--- + +## 3. Vertical Scale — Skyscrapers and Z-Level Cap + +How does a 50-floor skyscraper emerge? The current model (D-094) describes 3 z-levels for stations. Planet-side cities need more. + +### 3.1 The Z-Level Architecture + +*Let me be honest about what this means technically.* + +D-094 specifies 3 z-levels for the v0.1 station. But the chunk data structure has no hard limit on z-levels — `z_levels: u8` in the DistrictSkeleton is a count, not a cap. A 50-floor skyscraper = z-levels 0–49. + +The **real constraints** on vertical scale are: + +1. **Memory per chunk:** Each z-level adds a full 64×64 tile layer. At ~4 bytes per tile (tile ID + flags), that's ~16 KB per z-level per chunk. A 50-floor chunk = ~800 KB. A 4-chunk skyscraper footprint at 50 floors = ~3.2 MB. This is within budget — the LRU cache can hold it. + +2. **Chunk loading time:** Each z-level adds ~10-20ms to chunk fill (template stamping per floor). 50 floors = ~500ms–1s. This is right at the edge of the per-chunk budget. For skyscrapers, the mitigation is: **only fill floors the player is on + adjacent floors.** Floors 30–50 of a skyscraper don't need tile data until the player approaches them. + +3. **Rendering:** The Godot client renders one z-level at a time (the player's current floor) plus visibility into adjacent floors through stairwells, balconies, and open shafts. This is already the rendering model — it doesn't change with more floors. + +4. **Pathfinding:** NPCs need to navigate between floors. A 50-floor building with stairwells and elevators produces a tall navigation graph. The pathfinding cost scales linearly with z-levels used in a path (not total z-levels in the building). Most NPCs stay within 2-3 floors. Acceptable. + +### 3.2 How Skyscrapers Emerge + +A skyscraper is a **multi-block, multi-z-level reservation** in the DistrictSkeleton: + +```rust +struct MultiBlockReservation { + /// Which blocks this reservation covers (e.g., [(1,1), (1,2)] for 2-block footprint) + blocks: Vec<(u8, u8)>, + + /// Template tag for this multi-block structure + template_tag: String, + + /// Number of z-levels + z_levels: u8, + + /// Base z-level (usually 0 for ground-up construction) + base_z: u8, + + /// Function (residential tower, corporate HQ, government building, mixed-use) + function: ReservationFunction, + + /// Per-floor zone assignment (different floors can have different zones) + floor_zones: Vec<FloorZone>, +} + +struct FloorZone { + z_level: u8, + zone_type: ZoningType, + zone_palette: ZonePalette, + access_tier: AccessTier, +} +``` + +A 50-floor skyscraper occupies 1–4 blocks (2×2 max footprint) and reserves z-levels 0–49. The floor zone list assigns different functions to different floors: + +| Floors | Zone | Access | What's there | +|--------|------|--------|-------------| +| 0–2 | Commercial lobby | Public | Entrance, shops, reception | +| 3–10 | Office (lower) | Semi-private | Worker floors, open plan | +| 11–30 | Office (mid) | Private | Corporate, fewer NPCs per floor | +| 31–45 | Residential (luxury) | Restricted | Apartments, private balconies | +| 46–49 | Penthouse/Executive | Restricted+ | Power center, panoramic views | +| Basement (-1 to -3) | Service/Parking | Restricted | Maintenance, deliveries, the informal zone | + +### 3.3 Lazy Z-Level Loading + +The key optimization: **don't fill all 50 floors at once.** + +```rust +enum ZLevelLoadState { + /// Full tile data loaded (player is on or adjacent to this floor) + Loaded(ChunkData), + + /// Skeleton only — we know the floor plan and zone, but no tile data + Skeleton(FloorZone), + + /// Not yet generated — will be filled on demand + Ungenerated, +} +``` + +When the player enters a skyscraper at the lobby (z=0), only z=0, z=1, and z=-1 are filled. As they take an elevator to floor 20, floors 19–21 fill during the elevator "transit" time (elevator = a vertical mobile chunk, conceptually). The player never waits. + +**NPCs on unfilled floors:** NPCs on floors the player hasn't visited are simulated at reduced fidelity (D-026 simulation tiers). They have positions and states but no tile-level pathfinding. When the player arrives at their floor, the full tile data is generated and the NPC's position is resolved to specific tiles. + +### 3.4 Z-Level Cap + +**Practical cap: 64 z-levels.** This is a `u8` field, but 64 is the engineering recommendation: + +- 64 floors × 16 KB per floor per chunk = ~1 MB per chunk column. Manageable. +- 64 floors × 20ms fill time = ~1.3s for a full column fill (but lazy loading means you never do this). +- Beyond 64: diminishing returns. A 100-floor skyscraper has 36 floors of content the player likely never visits. Better to have 3 interesting 20-floor buildings than 1 boring 100-floor tower. + +For v0.1–v0.3, the effective cap remains 3 z-levels (station model). Skyscrapers (z > 3) are a v0.4+ feature gated on lazy z-level loading. + +### 3.5 Multi-Block Vertical Structures + +A skyscraper that spans 2×2 blocks (4 chunks footprint × 50 z-levels = 200 chunk-layers) is handled by the existing `MultiBlockReservation`. The reservation locks the block positions and assigns a shared template tag. During block planning, the reserved blocks get a `SkyscraperFootprint` chunk layout that overrides normal quarter-based generation. + +**Stairwells and elevators** are vertical connection points — tile positions that are passable between z-levels. They appear in the same position on every floor, creating a vertical spine through the building. The reservation template defines these positions; each floor's chunk fill respects them. + +### 3.6 Cost + +| Component | Effort | Target | +|-----------|--------|--------| +| `FloorZone` + per-floor zone assignment | 1 dev-day | v0.3 | +| Lazy z-level loading | 2 dev-days | v0.4 | +| Elevator as vertical transit | 1 dev-day | v0.4 | +| Skyscraper template (1 template) | 2 dev-days | v0.4 | +| NPC reduced-fidelity on unfilled floors | 1 dev-day | v0.4 | +| **Total** | **~7 dev-days** | **v0.4** | + +--- + +## 4. Dynamic World Modification — The Mutation Model + +A gas main explodes in a previously visited district. Does the generator re-render affected chunks? Or is destruction handled as an overlay? + +### 4.1 Principle: Overlays, Not Re-Generation + +**Chunks are never re-generated.** Once a chunk is filled (Phase 2), its `ChunkData` is canonical. Modifications are applied as **overlay mutations** on top of the base data. + +```rust +/// Mutations applied to an already-generated chunk. +/// Stored alongside the chunk in the save file. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct ChunkMutations { + /// Tile-level changes (destroyed walls, new debris, fire damage) + tile_overrides: Vec<TileOverride>, + + /// Structural changes (wall removed, floor collapsed) + structural_changes: Vec<StructuralChange>, + + /// New objects placed by simulation events + placed_objects: Vec<PlacedObject>, + + /// Objects removed by simulation events + removed_objects: Vec<ObjectId>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct TileOverride { + position: (u16, u16, u8), // x, y, z + new_tile: TileId, + cause: MutationCause, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum MutationCause { + Explosion { radius: u8, source: EntityId }, + Fire { spread_from: Option<(u16, u16)> }, + Construction { builder: EntityId }, + Decay { time_since_maintenance: u32 }, + PlayerAction { action: ActionId }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct StructuralChange { + /// Region affected (bounding box) + min: (u16, u16, u8), + max: (u16, u16, u8), + change_type: StructuralChangeType, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum StructuralChangeType { + /// Wall segment destroyed — creates passable tile + WallDestroyed, + /// Floor collapsed — creates hole to z-level below + FloorCollapsed, + /// Ceiling breached — creates opening to z-level above + CeilingBreached, + /// Area sealed — formerly passable area blocked by debris + AreaSealed, + /// New wall constructed + WallConstructed, +} +``` + +### 4.2 How Mutations Apply + +When the client renders a chunk, it applies mutations in order: + +1. Load base `ChunkData` (from cache or save file). +2. Apply `tile_overrides` — replace specific tiles with their mutated versions. +3. Apply `structural_changes` — update passability map for destroyed/created walls. +4. Apply `placed_objects` / `removed_objects` — modify object layer. +5. Render the result. + +**Why overlays, not re-generation:** +- Re-generation would require re-running the Phase 2 template stamping with modified inputs. But the template system is forward-only — it doesn't know how to "partially re-stamp" a chunk. +- Overlays are cheaper (apply a delta to existing data) and composable (multiple mutations stack). +- Overlays preserve player familiarity — the base layout is unchanged, the damage is visible on top of it. +- Overlays serialize cleanly to the save file — `base_chunk + mutations = current state`. + +### 4.3 Explosion Propagation + +A gas main explosion in the sim produces: + +1. **Sim event:** `Explosion { center: (x, y, z), radius: 8, force: High }`. +2. **Mutation generator:** For each tile within radius, evaluate wall/floor structural integrity. Walls within 3 tiles of center: `WallDestroyed`. Floors within 2 tiles: `FloorCollapsed`. Objects within radius: `removed_objects`. +3. **Debris placement:** `placed_objects` fills the blast zone with debris tiles (rubble, shattered glass, buckled panels). +4. **Fire spread:** Adjacent tiles with flammable objects may catch fire — additional `TileOverride` mutations applied over subsequent ticks. +5. **Chunk boundary:** If the explosion overlaps a chunk boundary, mutations are generated for both chunks. The mutation generator runs in world coordinates, not chunk-local coordinates. + +### 4.4 Previously Visited vs. Unvisited Districts + +**Previously visited (chunk exists in cache/save):** +- Mutations apply to the cached chunk. The player returns to find the damage. + +**Unvisited (chunk not yet generated):** +- The explosion event is recorded as a pending mutation on the `PreparedDistrict`. +- When the chunk is eventually generated (Phase 2), the mutation is applied immediately after generation. +- The player arrives to find a district that looks like it was built and then damaged — not a district that was never built. + +This second case is important: simulation events can affect districts the player hasn't visited. The architecture handles this by storing mutations at the district level and applying them either to existing chunks (if cached) or to newly generated chunks (at fill time). + +### 4.5 Cost + +| Component | Effort | Target | +|-----------|--------|--------| +| ChunkMutations struct + serialization | 1 dev-day | v0.2 | +| Mutation application in rendering pipeline | 2 dev-days | v0.3 | +| Explosion mutation generator | 2 dev-days | v0.4 | +| Pending mutations on unvisited districts | 1 dev-day | v0.3 | +| **Total** | **~6 dev-days** | **v0.2–v0.4** | + +--- + +## 5. Destructible Boundaries — What's Behind Walls + +When a player blasts through a wall, what does the generator reveal? + +### 5.1 The Problem + +Chunks are filled from templates. Templates define walls as boundaries. When a wall is destroyed, the tiles behind it must contain something. The question: does the generator always place geometry behind walls, or can walls be terminal boundaries with void behind them? + +### 5.2 The Answer: Bounded Void with Infill Rules + +**Walls can be terminal.** Not every wall has space behind it. But the player must never see void — they must see something that makes physical sense. + +```rust +enum WallBackside { + /// Another room/corridor exists behind this wall. + /// The tiles are already generated (part of the chunk data). + AdjacentSpace, + + /// Structural fill — solid material (concrete, rock, hull plating). + /// Destroying this wall reveals 1-2 tiles of fill, then another wall. + StructuralFill, + + /// Service void — narrow gap between structural walls. + /// 1-3 tiles deep, contains pipes/conduits, not navigable. + ServiceVoid, + + /// Chunk boundary — this wall is the edge of the chunk. + /// Destroying it reveals the adjacent chunk's boundary tiles. + ChunkBoundary, + + /// Exterior — this wall faces outside (hull, exterior wall). + /// Destroying it has catastrophic consequences (decompression, weather). + Exterior, +} +``` + +### 5.3 How the Generator Handles This + +At chunk fill time (Phase 2), every wall tile is tagged with its `WallBackside`: + +1. **Walls between rooms within the same chunk:** `AdjacentSpace`. Both sides are already generated. Destroying the wall just removes the LOS blocker — the tiles on both sides exist. + +2. **Walls at chunk boundaries:** `ChunkBoundary`. The adjacent chunk's edge tiles are the "backside." If the adjacent chunk is loaded, the player sees into it. If unloaded, the chunk loads on demand. Edge contracts guarantee compatible geometry. + +3. **Walls at the building exterior:** `Exterior`. Destroying these triggers a different consequence system (hull breach on a station, weather exposure on a planet). The tiles beyond are outdoor/void tiles. + +4. **Walls with no designed space behind them:** `StructuralFill`. The template places wall tiles in the "behind" positions — solid fill that reads as thick structural material. Destroying the wall reveals 1-3 tiles of fill (rubble, exposed conduit, insulation) and then another wall. The player CAN dig through structural fill, but it takes multiple actions and reveals only service-level geometry. + +5. **Walls against narrow utility gaps:** `ServiceVoid`. Template places 1-3 tiles of void with pipe/conduit objects. Destroyable but non-navigable (too narrow for a character, too full of infrastructure). Useful for gameplay: the player can see through the gap (modified LOS), hear through it, or pass small objects through. + +### 5.4 The Critical Rule + +**No tile in a generated chunk is ever "void" in the sense of "ungenerated."** Every tile has a type — even if that type is `SolidFill` or `HullPlating`. This means: + +- The player can never "break out of the map" by destroying walls. +- Every destructive action reveals something that makes physical sense. +- The computational cost of supporting destruction is bounded — we're not generating new content when walls break, we're revealing content that was always there but occluded. + +### 5.5 Template Authoring Requirement + +This adds a requirement to D-025 templates: **every wall tile must have a `WallBackside` tag.** Template authors need to specify what's behind each wall segment. For most walls, this is mechanical: + +- Interior walls between rooms: `AdjacentSpace` (auto-tagged during template stamping). +- Perimeter walls: `Exterior` or `ChunkBoundary` (auto-tagged based on position within chunk). +- Thick walls: `StructuralFill` (author decision — how thick is this building?). +- Walls adjacent to pipe runs: `ServiceVoid` (author places pipe objects behind the wall). + +90% of wall tagging can be automated. 10% is author choice that adds personality to the space. + +### 5.6 Cost + +| Component | Effort | Target | +|-----------|--------|--------| +| WallBackside tagging system | 1 dev-day | v0.3 | +| Auto-tagging in template stamping | 1 dev-day | v0.3 | +| StructuralFill + ServiceVoid tile types | 0.5 dev-days | v0.3 | +| Wall destruction → mutation pipeline | 1 dev-day | v0.4 | +| **Total** | **~3.5 dev-days** | **v0.3–v0.4** | + +--- + +## 6. Canonical DistrictSkeleton — Reconciled Definition + +The big reconciliation. My Round 2 additions + Gestalt's Round 2 additions + Round 3 requirements, unified into ONE canonical struct. + +### 6.1 Reconciling SignificanceTier / ComplexityTier / DramaDensity + +Qatux flagged that these three concepts overlap. Let me resolve this. + +**These are THREE distinct parameters, not one:** + +| Parameter | What it measures | Set by | Changes during gameplay? | +|-----------|-----------------|--------|------------------------| +| `SignificanceTier` | How important this location is in the galaxy network | Phase 1 (pre-pipeline, from seed + galaxy topology) | No — structural | +| `ComplexityTier` | How much generator content this district receives | Phase 1 (derived from SignificanceTier + TerrainType) | No — generator budget | +| `DramaDensity` | How much active drama the storyteller can inject | Storyteller (D-005, D-023), dynamic per session | **Yes** — the storyteller adjusts this | + +They are related but not redundant: + +- A **Center-stage** significant location with **Full** complexity might have **Zero** drama density in a seed where the storyteller has decided this world is quiet this playthrough. +- A **Backwater** significant location with **Moderate** complexity might have **High** drama density because the storyteller fired a Tier 1 module here. +- An **Insignificant** location with **Minimal** complexity ALWAYS has **Zero** drama density — the generator didn't produce enough social infrastructure for drama. + +**Resolution:** +- `SignificanceTier` and `ComplexityTier` stay on the DistrictSkeleton (static, generation-time). +- `DramaDensity` does NOT go on the DistrictSkeleton. It's a runtime storyteller parameter stored on the simulation state, not the generator output. The storyteller reads the DistrictSkeleton to know what's possible, then sets drama density dynamically. + +### 6.2 Reconciling SettingGeometry and TerrainType + +Gestalt proposed `SettingGeometry` (Station/Urban/Rural/Maritime/Wilderness/Specialized). I proposed `TerrainType` (Station/Urban/Agricultural/Wilderness/Water/Transitional/Orbital). These describe the same thing. + +**Resolution: Merge into `SettingType`.** Taking the union of both proposals: + +```rust +#[derive(Serialize, Deserialize, Clone, Debug)] +enum SettingType { + /// Station interior — zone-and-level grid, fully enclosed + Station, + + /// Planet-side city — terrain-influenced urban spread + Urban, + + /// Agricultural — low-density farmland/settlement + Agricultural, + + /// Maritime — coastal or aquatic, port-oriented + Maritime, + + /// Wilderness — minimal infrastructure + Wilderness { biome: Biome }, + + /// Water body — ocean, lake, river + Water { water_type: WaterType }, + + /// Transitional — urban edge, suburbs, outskirts + Transitional, + + /// Orbital — small installation, different geometry + Orbital, + + /// Specialized single-function (resort, research, military) + Specialized { function: SpecializedFunction }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum SpecializedFunction { + Resort, Research, Military, Mining, Religious, +} +``` + +### 6.3 TrianglePurpose — Simple Tag, Not Complex + +Gestalt proposed adding `triangle_purpose: TrianglePurpose` to `SocialSitePlacement.triangles`. Does this add implementation complexity? + +**No.** It's a simple enum tag on an existing struct. The triangle template already exists; this is one additional field. + +```rust +#[derive(Serialize, Deserialize, Clone, Debug)] +enum TrianglePurpose { + /// Economic conflict — competition, trade dispute, resource control + Economic, + /// Political conflict — power struggle, factional, institutional + Political, + /// Social conflict — personal, romantic, loyalty + Social, + /// Investigation — evidence trail, conspiracy link, surveillance target + Investigation, + /// Mundane — workplace friction, neighbor dispute, family tension + Mundane, +} +``` + +A triangle can have multiple purpose tags (a romantic rivalry that's also political = `[Social, Political]`). The scenario instantiation stage activates triangles based on which purposes are relevant to the current gameplay context. Implementation: one `Vec<TrianglePurpose>` field on the triangle assignment. ~20 lines of code. + +### 6.4 The Canonical DistrictSkeleton + +Here it is. Every field from both my Round 2 and Gestalt's Round 2, reconciled and unified. + +```rust +/// The canonical generator output for one district. +/// Produced by Phase 1. Consumed by Phase 2. +/// This is the contract between world prep and local generation. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct DistrictSkeleton { + // ── Identity ────────────────────────────────────────── + district_id: DistrictId, + seed: u64, + district_type: DistrictType, + context: DistrictContext, + + // ── Classification (from Gestalt + Tyre, reconciled §6.1) ── + /// How important this location is in the galaxy network + significance: SignificanceTier, + /// How much generator content this district receives + complexity: ComplexityTier, + /// Physical setting type (merged SettingGeometry + TerrainType) + setting: SettingType, + /// Layout mode: grid or organic (§1) + layout_mode: DistrictLayoutMode, + + // ── Spatial Structure ───────────────────────────────── + /// The 4×4 block grid + blocks: [[BlockSkeleton; 4]; 4], + /// Multi-block reservations (skyscrapers, parks, terminals) + reservations: Vec<MultiBlockReservation>, + /// Corridor spines connecting key access points + corridors: Vec<CorridorSpine>, + /// Z-level count for this district + z_levels: u8, + + // ── Social Structure ────────────────────────────────── + /// Social site placements with template tags and triangle configs + social_sites: Vec<SocialSitePlacement>, + /// District-level access points (entries/exits to neighboring districts) + access_points: Vec<AccessPoint>, + + // ── Cultural/World Context ──────────────────────────── + /// Society profile reference (serde YAML, Miri's ingredients) + society_profile: SocietyProfileRef, + /// Zone palette definitions for this district + zone_palette: Vec<ZoneDefinition>, + + // ── Boundary System (edge bleed, §Round 2) ─────────── + /// Edge descriptors for transition strips with neighbors + boundaries: DistrictBoundaries, + + // ── Validation ──────────────────────────────────────── + /// Gestalt's 11-check guarantee audit result. + /// Records which spatial archetypes are satisfied and where. + /// Only populated for ComplexityTier::Full districts. + guarantee_audit: Option<GuaranteeAuditResult>, +} + +/// Significance in the galaxy network. +/// Set at pre-pipeline from seed + galaxy topology. +#[derive(Serialize, Deserialize, Clone, Debug)] +enum SignificanceTier { + /// Hub world — multiple districts, full content, high faction pressure + CenterStage, + /// Regional importance — 1-4 districts, moderate content + Regional, + /// Small community — 1 district, local-only importance + Backwater, + /// Transit stop — minimal, pass-through only + Waypoint, + /// Not generated until player approaches + Insignificant, +} + +/// How much generator budget this district receives. +/// Derived from SignificanceTier + SettingType. +#[derive(Serialize, Deserialize, Clone, Debug)] +enum ComplexityTier { + /// Full gameplay — all spatial guarantees, rich NPC population + Full, + /// Moderate — partial guarantees, moderate NPCs + Moderate, + /// Minimal — pass-through, sparse NPCs, no gameplay guarantees + Minimal, + /// Empty — no social sites, no NPCs, pure terrain + Empty, +} + +/// Per-block skeleton. +struct BlockSkeleton { + position: (u8, u8), + zoning: ZoningType, + reservation: Option<ReservationId>, + chunk_layout: ChunkLayout, + hosted_sites: Vec<SocialSiteId>, + + /// Construction era + era: Era, + /// Era modifications (retrofits, additions) + era_modifications: Vec<EraModification>, + /// Gestalt addition: WHY the era differs from district norm + era_cause: Option<EraCause>, + + /// Density parameter (0.0-1.0) for quarter fill + density: f32, + /// Landmark reservation (at most 1 per district quadrant) + landmark: Option<LandmarkSlot>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum EraCause { + /// Original construction — no deviation from district era + Original, + /// Corporate merger/acquisition changed ownership + CorporateMerger, + /// Emergency extension after capacity crisis + EmergencyExtension, + /// Organic growth over time + OrganicGrowth, + /// Institutional incursion (Commission, Syndic) + InstitutionalIncursion, + /// Economic disruption (abandonment/repurposing) + EconomicDisruption, + /// Cultural shift (new community moved in) + CulturalShift, +} + +/// Social site placement with triangle configuration. +struct SocialSitePlacement { + site_id: SocialSiteId, + /// Which blocks this site occupies + blocks: Vec<(u8, u8)>, + /// D-025 template tag + template_tag: String, + /// Access tier + access_tier: AccessTier, + /// Triangle assignments for this site + triangles: Vec<TriangleAssignment>, + /// NPC role slots (filled in the NPC population stage) + role_slots: Vec<RoleSlot>, + /// Active day-phases for this social site (D-031) + active_phases: Vec<DayPhase>, +} + +struct TriangleAssignment { + template: TriangleTemplate, + /// Gestalt addition: what this conflict is FOR + purposes: Vec<TrianglePurpose>, + /// Which role slots are involved + participants: Vec<RoleSlotId>, + /// Spatial requirement (staging ground block) + staging_block: Option<(u8, u8)>, +} + +/// Gestalt's 11-check guarantee audit. +/// Serialized into the skeleton for validation and debugging. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct GuaranteeAuditResult { + /// 7 spatial archetype checks + traffic_chokepoint: AuditCheck, + informal_zone: AuditCheck, + social_hub: AuditCheck, + institutional_space: AuditCheck, + insider_space: AuditCheck, + economic_node: AuditCheck, + encounter_corridor: AuditCheck, + + /// 4 playstyle-specific checks + economic_asymmetry_signal: AuditCheck, + temporal_encounter_window: AuditCheck, + power_gradient_visibility: AuditCheck, + density_contrast: AuditCheck, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct AuditCheck { + passed: bool, + /// Which social site / block satisfies this check + satisfied_by: Option<SatisfiedBy>, +} + +enum SatisfiedBy { + SocialSite(SocialSiteId), + Block(u8, u8), + Corridor(CorridorSpineId), +} +``` + +### 6.5 Memory Budget (Revised) + +With all Round 3 additions: + +| Component | Size per district | Notes | +|-----------|------------------|-------| +| Identity + classification | ~64 bytes | Fixed | +| Blocks (4×4 × BlockSkeleton) | ~2 KB | Increased from R2 with era_cause, density, landmark | +| Social sites + triangles | ~1-4 KB | Depends on complexity tier | +| Reservations + corridors | ~0.5-2 KB | Depends on multi-block structures | +| Boundaries | ~4 KB | 4 edges × ~1 KB | +| Society profile ref | ~32 bytes | Reference, not full profile | +| Zone palette | ~0.5 KB | | +| Guarantee audit | ~256 bytes | 11 checks | +| Layout mode (organic) | 0-1 KB | Only for organic mode | +| **Total per district** | **~8-14 KB** | Up from 5-10 KB in Round 2 | + +300 worlds × ~6 districts average × ~12 KB = ~21 MB for all skeletons. Still trivial. + +--- + +## 7. Palette Granularity — Modifiers, Not More Base Palettes + +The lead says: 5 non-urban palettes is too few. Industrial farming ≠ rustic farming. + +### 7.1 The Architecture: Base Palette + Modifiers + +The answer is **palette modifiers**, not more base palettes. Same architecture that era modifications use for urban palettes. + +```rust +#[derive(Serialize, Deserialize, Clone, Debug)] +struct ZonePalette { + /// Base palette type (the 5 urban + 6 natural from Araminta) + base: BasePalette, + /// Modifiers that shift the palette without replacing it + modifiers: Vec<PaletteModifier>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum BasePalette { + // Urban (from Round 1/2) + GateCluster, Terminal, Maintenance, Social, Residential, + Industrial, Commercial, Administrative, Cargo, + + // Natural (from Araminta Round 2) + Farmland, WildernessForest, OceanCoastal, Beach, + MountainSnow, SecludedTown, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum PaletteModifier { + /// Economic function shifts the palette character. + /// Industrial farming = Farmland + Industrial modifier. + /// Rustic farming = Farmland + no modifier (base). + EconomicFunction(EconomicModifier), + + /// Era modifier — older/newer construction on the base palette. + Era(Era), + + /// Faction modifier — institutional presence shifts lighting/materials. + FactionPresence(FactionModifier), + + /// Condition modifier — well-maintained vs. decaying. + Condition(ConditionModifier), + + /// Cultural modifier — heritage root shifts material choices. + Heritage(HeritageRoot), + + /// Seasonal modifier (agricultural/natural terrain only). + Season(Season), +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum EconomicModifier { + /// Industrial-scale: larger equipment, uniform rows, metal infrastructure + Industrial, + /// Artisanal/traditional: smaller scale, varied, wood/stone materials + Artisanal, + /// Corporate: branded, maintained, standardized + Corporate, + /// Subsistence: minimal infrastructure, improvised + Subsistence, + /// Luxury: high-quality materials, careful maintenance + Luxury, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum ConditionModifier { + /// Well-maintained — clean lines, functioning fixtures + Maintained, + /// Worn — functional but showing age + Worn, + /// Neglected — failed fixtures, cracked surfaces + Neglected, + /// Abandoned — no maintenance, natural reclamation + Abandoned, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum Season { + Growth, // green, active crops + Harvest, // golden, busy + Dormant, // brown, sparse + Snow, // white overlay +} +``` + +### 7.2 How Modifiers Compose + +A `ZonePalette` is the base palette plus a stack of modifiers. At chunk fill time, the renderer resolves the final hex values: + +**Example: Industrial farming** +- Base: `Farmland` (dark warm brown soil, amber sparse lighting) +- Modifier: `EconomicFunction(Industrial)` → shift floor toward grey-metal tones, add uniform-row crop patterns, replace wood fencing with metal, increase object density +- Modifier: `Condition(Maintained)` → clean lines, functioning equipment +- Result: industrialized farmland that still reads as "farmland" but with metal silos, irrigation machinery, and uniform crop rows + +**Example: Abandoned rustic farm** +- Base: `Farmland` +- Modifier: `EconomicFunction(Artisanal)` → no shift from base (artisanal IS the farmland base) +- Modifier: `Condition(Abandoned)` → failed fixtures, natural reclamation (overgrown), darkened surfaces +- Modifier: `Season(Dormant)` → brown/sparse crop cover +- Result: an abandoned traditional farm in winter — melancholy, overgrown, quietly decaying + +### 7.3 Combination Space + +With 15 base palettes × 5 economic modifiers × 4 condition modifiers × (optional era, faction, heritage, season) = **300+ distinct palette combinations** from a compact set of primitives. + +This addresses the lead's concern: "industrial farming ≠ rustic farming" is solved by `Farmland + Industrial` vs `Farmland + Artisanal`. No new base palette needed. The modifier system is the palette expansion mechanism. + +### 7.4 Cost + +The modifier system is a rendering-time concern, not a generator-time concern. The generator assigns the `ZonePalette` (base + modifiers) at Phase 1. The renderer resolves final hex values at Phase 2 chunk fill. + +| Component | Effort | Target | +|-----------|--------|--------| +| PaletteModifier enum + resolver | 2 dev-days | v0.3 | +| Integration with chunk fill palette lookup | 1 dev-day | v0.3 | +| Test palette combinations (Araminta visual review) | 1 dev-day | v0.3 | +| **Total** | **~4 dev-days** | **v0.3** | + +--- + +## 8. Updated Cost Summary — All Rounds Combined + +Here's the full cost picture including Rounds 1-3. + +| System | Dev-Days | Target | Risk | +|--------|----------|--------|------| +| **Core Pipeline** | | | | +| SeedChain + derivation | 0.5 | v0.2 | Low | +| SocietyProfile serde schema | 1 | v0.2 | Low | +| DistrictSkeleton (canonical, with all R3 fields) | 3 | v0.1 stub, v0.2 impl | Low | +| BlockPlan + ChunkFillSpec | 1 | v0.1 stub | Low | +| Phase 2 chunk fill (template stamping) | 5 | v0.2 | Medium | +| Transit District validation fixture | 3 | v0.1 | Low | +| **Sub-total core** | **13.5** | | | +| | | | | +| **Edge Bleed + Transitions** | | | | +| TransitionStrip generation | 2 | v0.3 | Low | +| Palette modifier system | 4 | v0.3 | Low | +| **Sub-total transitions** | **6** | | | +| | | | | +| **Grid Breathing** | | | | +| Organic layout mode | 3 | v0.3 | Medium | +| **Sub-total grid** | **3** | | | +| | | | | +| **Vertical Scale** | | | | +| FloorZone + per-floor zones | 1 | v0.3 | Low | +| Lazy z-level loading | 2 | v0.4 | Medium | +| Skyscraper template + elevator | 3 | v0.4 | Medium | +| NPC reduced-fidelity on unfilled floors | 1 | v0.4 | Low | +| **Sub-total vertical** | **7** | | | +| | | | | +| **Mobile Chunks** | | | | +| MobileChunk data model | 1 | v0.3 | Low | +| Docked state + access points | 2 | v0.3 | Medium | +| InTransit exterior scroll | 3 | v0.3 | Medium | +| InterSystem void state | 0.5 | v0.3 | Low | +| Mobile templates (3-4) | 2 | v0.3 | Low | +| NPC routines on mobile chunks | 1 | v0.3 | Low | +| **Sub-total mobile** | **9.5** | | | +| | | | | +| **Dynamic Modification** | | | | +| ChunkMutations struct | 1 | v0.2 | Low | +| Mutation rendering pipeline | 2 | v0.3 | Medium | +| Explosion mutation generator | 2 | v0.4 | Medium | +| Pending mutations on unvisited | 1 | v0.3 | Low | +| WallBackside tagging | 1.5 | v0.3 | Low | +| Wall destruction pipeline | 1 | v0.4 | Low | +| **Sub-total destruction** | **8.5** | | | +| | | | | +| **Other** | | | | +| Spatial prerequisite validator | 0.5 | v0.3 | Low | +| Phase 1 background thread | 2 | v0.3 | Low | +| ComplexityTier + minimal/empty gen | 1 | v0.3 | Low | +| TerrainType + non-urban chunk fill | 3 | v0.4+ | Medium | +| **Sub-total other** | **6.5** | | | +| | | | | +| **GRAND TOTAL** | **~54 dev-days** | **v0.1–v0.4** | | + +### Milestone Breakdown + +| Milestone | Dev-Days | What Ships | +|-----------|----------|-----------| +| **v0.1** | ~7 | DistrictSkeleton stub, BlockPlan stub, Transit District validation fixture | +| **v0.2** | ~10.5 | SeedChain, SocietyProfile, DistrictSkeleton impl, Phase 2 chunk fill, ChunkMutations struct | +| **v0.3** | ~22.5 | Edge bleed, palette modifiers, organic layout, mobile chunks (docked+transit), mutation rendering, wall backside, lazy z-levels, Phase 1 background thread | +| **v0.4** | ~14 | Skyscrapers, explosion mutations, non-urban terrain, NPC reduced-fidelity floors | + +v0.1–v0.2 is the foundation. v0.3 is where the generator becomes a real system. v0.4 extends it to the ambitious features (vertical scale, destruction, non-urban terrain). + +--- + +## 9. Open Questions Remaining After Round 3 + +### OQ-R3-F: Organic Mode Edge Contract Resolution + +When two blocks are rotated by different amounts, the shared boundary is a diagonal strip rather than a clean 64-tile line. The exact algorithm for resolving edge contracts across rotated boundaries needs prototyping. **Recommendation:** Prototype this as the first task in v0.3 organic mode work. If it proves too complex, fall back to offsets-only (no rotation), which still breaks the grid significantly. + +### OQ-R3-G: Multi-Chunk Mobile Vehicles + +Large spaceships (2×2+ chunks) are deferred to v0.5+. The data model (a mobile "block" containing multiple mobile chunks with internal boundaries) is straightforward in principle but needs careful design around inter-chunk access within a moving vessel. **Not blocking for v0.3.** + +### OQ-R3-H: Elevator as Vertical Mobile Chunk + +An elevator is conceptually a mobile chunk that moves vertically within a building rather than horizontally through the world. The MobileChunk model could handle this, but it's architecturally simpler to treat elevators as instantaneous z-level transitions (like staircases but with a loading screen). **Recommendation:** Simple transition for v0.4, full elevator-as-mobile-chunk for v0.5+ if the gameplay warrants it. + +--- + +*Tyre — Round 3 complete. Seven directives addressed. The DistrictSkeleton is canonical. The grid can breathe. Mobile chunks are core. Vertical scale is lazy-loaded. Destruction is overlay-based. Walls have backsides. Palettes compose through modifiers. ~54 dev-days spread across v0.1–v0.4. The architecture is ready for the D-record.* diff --git a/docs/workshops/generator-architecture/tyre-round4.md b/docs/workshops/generator-architecture/tyre-round4.md new file mode 100644 index 000000000..84c92e5f4 --- /dev/null +++ b/docs/workshops/generator-architecture/tyre-round4.md @@ -0,0 +1,1047 @@ +# Round 4: Tyre — Architecture Finalization + +**Workshop:** Generator Architecture (#562) +**Agent:** Tyre (Technical Architect) +**Date:** 2026-02-27 + +**Round 4 scope:** Final convergence. Five specific assignments from lead. This is the version that goes into the D-record. + +--- + +## 1. OQ-R4-A: Final MobileChunk Specification + +Lead mandate: entity-carried chunks for trains, ships, spaceships, traincars. My MobileChunk model wins structurally. The question from Nigel: can vessel interiors be simpler than full districts while using the same entity-carried architecture? + +### 1.1 The Answer: YES — Emphatically + +Nigel's concern is valid and important. His instanced-district model was attractive because it was simple — vessel as a temporary district instance, same data path as everything else. My entity-carried model is more powerful, but Nigel is right to ask: does "more powerful" mean "more complex generation"? + +No. The entity-carried architecture describes **how the chunk exists in the world** (attached to an entity, with movement states). It says nothing about **how complex the chunk's interior is**. These are orthogonal. + +*cracks knuckles* — Let me be explicit about the simplification layers. + +### 1.2 Vessel Interior Simplification + +Vessel interiors are NOT full districts. They don't use the district generation pipeline. They're template-stamped chunks with NPC role slots: + +| Property | Full District | Vessel Interior | +|----------|--------------|-----------------| +| Generation pipeline | Phase 1 → Phase 2 (skeleton → fill) | Template stamp only (no skeleton stage) | +| Spatial archetype guarantees | 3-11 checks per complexity tier | **None.** Vessel templates are hand-authored. | +| Block/quarter system | 4×4 blocks, quarter-based fill | **None.** Single template per vessel class. | +| NPC generation | Full 10-axis generation + entanglement | **Roster seeding only.** Crew = fixed roles from template. Passengers = seeded from route's NPC pool at departure. | +| Triangle system | Multi-triangle per social site | **One triangle max** per vessel (the confined-social-pressure triangle) | +| ComplexityTier | Full / Moderate / Minimal / Empty | **Not applicable.** Vessel interiors don't have a ComplexityTier — they have a `VesselClass`. | +| Zone palette | Full modifier stack | **Single palette** per vessel class (luxury liner vs cargo hauler vs patrol boat) | + +The point: a MobileChunk's entity-carrying architecture (docking, transit states, world persistence) is infrastructure that costs ~9.5 dev-days once. The *interior content* is as simple or as complex as the template demands. A cargo train car interior is 32×8 sim tiles with a crew NPC and some crates. That's trivially cheap to generate. + +### 1.3 Vessel Classes + +Instead of ComplexityTier, vessels have a `VesselClass` that determines interior scope: + +```rust +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +enum VesselClass { + /// Small vehicle: shuttle, skiff, small boat. + /// Interior: 16×8 to 32×16 sim tiles. 0-2 NPCs (crew only). + /// No social sites. No triangles. Pure transit. + Small, + + /// Standard vehicle: train car, medium ship, orbital shuttle. + /// Interior: 32×16 to 64×32 sim tiles. 2-8 NPCs (crew + passengers). + /// One social site (the common area). Optional triangle. + Standard, + + /// Large vehicle: passenger liner, freighter, long-haul train. + /// Interior: 64×64 sim tiles (full chunk). 8-30 NPCs. + /// Multiple social sites (dining, bar, deck, cabins). 1-2 triangles. + /// This is the "social pressure cooker" Nigel described. + Large, + + /// Capital vessel: warship, colony ship, station-scale transport. + /// Interior: 2×2 chunks (128×128 sim tiles — a mobile "block"). + /// 30+ NPCs. Full social site complement. Multiple triangles. + /// v0.5+ feature. Deferred. + Capital, +} +``` + +### 1.4 The Final MobileChunk Struct + +```rust +/// A chunk attached to a mobile entity instead of fixed coordinates. +/// Companion struct to DistrictSkeleton — NOT a district, but a +/// first-class world entity with its own generation and streaming rules. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct MobileChunk { + // ── Identity ────────────────────────────────────────── + /// Entity this chunk is attached to + entity_id: EntityId, + /// Vessel class determines interior scope + vessel_class: VesselClass, + /// Template tag for interior generation + template_tag: VesselTemplateTag, + /// Seed for cosmetic variation (different cargo, wear, graffiti) + interior_seed: u64, + + // ── Chunk Data ──────────────────────────────────────── + /// The chunk data itself — same format as static chunks. + /// None if not yet generated (generated on first player entry). + data: Option<ChunkData>, + /// Interior dimensions in sim tiles (may be smaller than 64×64). + /// Actual ChunkData is always 64×64; unused tiles are impassable. + interior_size: (u16, u16), + + // ── World Presence ──────────────────────────────────── + /// Current movement state + movement: MobileMovementState, + /// Connection points to the outside world (doors, airlocks, gangways). + /// Active only when the entity is docked/stopped. + access_points: Vec<MobileAccessPoint>, + + // ── Social Content ──────────────────────────────────── + /// Crew roster (fixed roles from vessel template) + crew_slots: Vec<CrewSlot>, + /// Passenger manifest (seeded at journey departure, empty when idle) + passenger_manifest: Vec<PassengerId>, + /// Social site placements within the vessel interior + social_sites: Vec<VesselSocialSite>, + /// Triangle assignments (max 2 for Large, 0 for Small) + triangles: Vec<TriangleAssignment>, + + // ── Mutations ───────────────────────────────────────── + /// Post-generation modifications (same as static chunks) + mutations: ChunkMutations, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum MobileMovementState { + /// Docked at a fixed position — accessible from the world. + /// Vessel is visible on the map. Interior persists in save state. + Docked { + dock_position: WorldPosition, + /// Which static chunk the door connects to (None if free-floating dock) + connected_chunk: Option<ChunkCoord>, + }, + + /// In transit along a route — interior accessible, exterior scrolls. + /// Client renders scrolling background through window tiles. + InTransit { + route: RouteId, + progress: f32, // 0.0–1.0 along route + speed: f32, // sim tiles per tick + }, + + /// Between star systems — only the interior exists. + /// Maximum social confinement. No exit until arrival. + InterSystem { + origin: SystemId, + destination: SystemId, + progress: f32, + }, + + /// Idle at a location — not docked to infrastructure, not in transit. + /// Vessel is parked (anchored ship, grounded shuttle). + /// Visible on map. Access requires approach (swimming, walking to it). + Idle { + world_position: WorldPosition, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct MobileAccessPoint { + /// Position within the mobile chunk (interior coordinates) + interior_position: (u16, u16), + /// Direction the access point faces (relative to entity heading) + facing: CardinalDirection, + /// What kind of connection (gangway, airlock, cargo door) + connection_type: MobileConnectionType, + /// Is this access point currently active? + active: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum MobileConnectionType { + /// Standard door/gangway — pedestrian access + Gangway, + /// Airlock — required for station-to-vessel in vacuum + Airlock, + /// Cargo door — large, ground-level, vehicle-accessible + CargoDoor, + /// Emergency hatch — always available but triggers alarm + EmergencyHatch, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct CrewSlot { + role: CrewRole, + /// NPC assigned to this slot (None if position unfilled) + assigned_npc: Option<NpcId>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum CrewRole { + Captain, Pilot, Engineer, Navigator, + Steward, Security, Cook, Medic, + Deckhand, // generic crew +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct VesselSocialSite { + site_type: VesselSiteType, + /// Tile region within the interior + bounds: TileRect, + /// NPC role slots for this site + role_slots: Vec<RoleSlot>, + /// Active day-phases (D-031) + active_phases: Vec<DayPhase>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum VesselSiteType { + /// Where passengers and crew gather (dining, bar, common room) + CommonArea, + /// Bridge/helm — captain and officers + Bridge, + /// Passenger cabins — private, low-traffic + Cabins, + /// Cargo hold — crew-only, storage, potential informal zone + CargoHold, + /// Deck/observation — open area with exterior view + Observation, +} +``` + +### 1.5 Persistent Docked Vessels — The Key Advantage + +This is what Nigel's instanced model could NOT do, and why the lead chose entity-carried: + +**A ship docked at Port Sova is a world entity.** The player walks along the dock and sees the vessel sprite. NPCs know it's there — dock workers are loading cargo, a crew member is smoking at the gangway. The ship has been there since Tuesday and will depart Thursday. This is world presence, not an instance that pops into existence when you buy a ticket. + +The `Docked` state enables: +- **Investigation opportunity:** The player can board a docked vessel before departure. The crew is aboard. The cargo manifest is discoverable. The ship has history (mutations from previous voyages). +- **Tycoon opportunity:** The docked vessel represents cargo capacity. Its departure schedule is a trade window. +- **Assassination opportunity:** A target boarding a vessel tomorrow means the player has until departure to act — or they follow the target aboard and the vessel becomes a sealed environment. +- **World texture:** Ports feel alive because vessels arrive and depart. The dock district's NPC density varies with the vessel schedule. + +Vessel lifecycle: +1. **Spawned** at system generation (seeded: which vessels exist, which routes they serve) +2. **Docked** at a port (idle between voyages — world entity, visible, boardable) +3. **Passengers board** at departure time (manifest seeded from departure location's NPC pool) +4. **InTransit** (interior accessible, exterior scrolls) +5. **Arrives** at destination (passengers disembark, vessel enters Docked/Idle at new port) +6. **Cycle repeats** (scheduled routes) or vessel enters Idle (unscheduled) + +The vessel persists across the entire lifecycle. Its interior state, crew, and mutations carry forward. A ship the player damaged three voyages ago still has the scar. + +### 1.6 Addressing Nigel's Cost Concern + +Nigel's instanced district model was cheaper. He's right — the instanced model is simpler to implement. But the cost delta is smaller than it appears: + +| Component | Entity-carried | Instanced district | Delta | +|-----------|---------------|-------------------|-------| +| Data structure | MobileChunk (~100 LOC) | DistrictSkeleton reuse (~0 LOC) | +100 LOC | +| Docked state | 2 dev-days | Not applicable | +2 dev-days | +| InTransit scroll | 3 dev-days | 3 dev-days (same visual requirement) | 0 | +| InterSystem | 0.5 dev-days | 0.5 dev-days | 0 | +| Templates | 2 dev-days | 2 dev-days | 0 | +| NPC routines | 1 dev-day | 1 dev-day | 0 | +| World persistence | 1 dev-day | Not applicable | +1 dev-day | +| **Total** | **~9.5 dev-days** | **~6.5 dev-days** | **+3 dev-days** | + +Three dev-days buys us: persistent docked vessels, vessel history/mutations across voyages, world-present ships visible at ports, investigation/boarding before departure, dock NPCs that interact with specific vessels. + +That's an excellent trade. The simplifications in §1.2 already addressed the complexity concern for generation. The only additional cost is the world-presence plumbing, and that's infrastructure the game needs regardless. + +### 1.7 Streaming Integration + +One mobile chunk loaded per player at a time (v0.1–v0.4 constraint). The loading rules: + +- **Player outside vessel:** Vessel is a sprite. Interior not loaded. +- **Player enters docked vessel:** MobileChunk loads. Surrounding static chunks remain loaded (dock area). +- **Vessel departs (InTransit):** Static chunks around departure dock unload as they leave the loading grid. MobileChunk stays loaded. Exterior transitions to scrolling visual buffer. +- **Vessel arrives:** Destination dock static chunks load. Access points activate. Player can exit. +- **Player exits vessel:** MobileChunk drops from active loading grid (but persists in world state — crew continues reduced-fidelity simulation). + +--- + +## 2. OQ-R4-B: WorldTier Rename + +Lead decision: rename `SignificanceTier` to `WorldTier`. Adopting Nigel's naming and tier definitions. + +### 2.1 The Renamed Enum + +```rust +/// How important this location is in the galaxy network. +/// Set at system generation from seed + galaxy topology. +/// Constrains the maximum achievable ComplexityTier. +/// IMMUTABLE after generation. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)] +enum WorldTier { + /// Maximum connectivity, major faction presence, historically significant. + /// Multiple districts. Hub of trade, politics, culture. + /// Can support: Full or Moderate complexity. + Epicenter, + + /// Meaningful connectivity, notable faction presence, relevant to the wider network. + /// 1-4 districts. Significant but not central. + /// Can support: Full, Moderate, or Minimal complexity. + Regional, + + /// Transit-relevant primarily. Light faction footprint. + /// Functionally important for travel but not socially deep. + /// 1-2 districts. Pass-through with some infrastructure. + /// Can support: Moderate or Minimal complexity. + Passage, + + /// Low external connectivity, weak external faction presence. + /// Self-contained community. 1 district. + /// Can support: Full (dense isolated community), Moderate, or Minimal. + Backwater, + + /// Minimal or no social complexity. Geography/transit function only. + /// Fuel stop, relay station, uninhabited. + /// Can support: Minimal or Empty complexity. + Waypoint, +} +``` + +### 2.2 The Constraint Matrix + +WorldTier constrains the maximum ComplexityTier. ComplexityTier constrains the maximum DramaDensity. The chain: + +``` +WorldTier → constrains → ComplexityTier → constrains → DramaDensity (runtime) +``` + +| WorldTier | Max ComplexityTier | Rationale | +|-----------|-------------------|-----------| +| Epicenter | Full | Has the population and infrastructure | +| Regional | Full | Can sustain full complexity in main district | +| Passage | Moderate | Transit-focused; infrastructure serves movement, not residence | +| Backwater | Full | KEY INSIGHT: a Backwater can be Full. Dense isolated community. | +| Waypoint | Minimal | Not enough social structure for more | + +| ComplexityTier | Max DramaDensity | Rationale | +|---------------|-----------------|-----------| +| Full | Flashpoint | Has enough NPCs and social fabric for maximum drama | +| Moderate | High | Enough for multi-thread drama, not enough for full Flashpoint | +| Minimal | Low | A few NPCs, one thread at most | +| Empty | Zero | No social infrastructure, no drama possible | + +### 2.3 The Backwater + Full Case + +This is the single most important combination in the matrix and deserves emphasis. + +A Backwater world with Full complexity is a **dense, isolated community rich with human drama**. Think: fishing village of 200 people who've known each other for 40 years. No galactic significance. No faction presence. But every NPC has a history with every other NPC. The information asymmetry inverts (Miri's insight): the player can't be anonymous. Everyone knows their name within hours. The conspiracy here is intimate — not cargo smuggling or political assassination, but decades of personal entanglement. + +This is a fundamentally different game experience from an Epicenter + Full district. The generator produces it by allowing WorldTier and ComplexityTier to be independent. The combination space is the game's variety. + +### 2.4 What Changes in the Codebase + +The rename is purely nominal. Everywhere the codebase references `SignificanceTier`, replace with `WorldTier`. Everywhere it says `significance:`, replace with `world_tier:`. The enum values change: + +| Old (SignificanceTier) | New (WorldTier) | +|----------------------|-----------------| +| CenterStage | Epicenter | +| Regional | Regional | +| *(no equivalent)* | Passage | +| Backwater | Backwater | +| Waypoint | Waypoint | +| Insignificant | *(absorbed into Waypoint)* | + +The addition of `Passage` (between Regional and Backwater) and the absorption of `Insignificant` into `Waypoint` are the substantive changes. A transit hub that has some social structure but isn't a residential community was awkwardly represented as either Regional (too much) or Backwater (wrong connotation). `Passage` captures this correctly. + +--- + +## 3. OQ-R4-F: Gas Explosion — XOR vs. Structured Re-Generation + +Gestalt proposed `original_seed XOR event_seed` for large-scale events that affect too many tiles for the overlay model. The question: does XOR produce results that look *caused* or *random*? + +*Let me be honest about what this means technically.* + +### 3.1 The Concrete Scenario + +**Setup:** Block (2,1) of a logistics district. Chunk (4,2) — a warehouse zone adjacent to a gas main. The chunk has been visited and is cached. The warehouse has: +- A 20×30 floor area with cargo racks (rows of crates, 2-tile aisles) +- A loading dock on the south face (8 tiles wide) +- An office in the northeast corner (8×6 tiles, desk, terminal, filing) +- A maintenance corridor along the west wall (2 tiles wide, pipe runs) +- Gas main runs under the maintenance corridor + +**Event:** Gas main rupture → explosion. Blast radius: 12 sim tiles from the rupture point (midpoint of west wall). Force: High. + +### 3.2 Approach A: XOR Re-Seeded Re-Generation + +``` +original_chunk_seed = 0xA7B3C1D4E5F60718 +event_seed = 0x00000000DEADBEEF (gas_explosion event) +new_chunk_seed = 0xA7B3C1D4BA530EA7 (XOR result) +``` + +Phase 2 re-runs with `new_chunk_seed`. The template stamper generates a *completely new* chunk from this seed. Result: + +``` +ORIGINAL (seed A7B3...): XOR RE-GENERATED (seed A7B3...BA53...): + +┌─────────────────────┐ ┌─────────────────────┐ +│ ┌──────┐ │ │ │ +│ │Office│ Cargo │ │ Open floor plan │ +│ │ │ racks │ │ (different layout) │ +│ └──────┘ ║║║║║║ │ │ ┌─────┐ ┌──────┐│ +│ pipes ║║║║║║║║║║ │ │ │Break │ │Tool ││ +│ ║ ║║║║║║║║║║ │ │ │room │ │shop ││ +│ ║ ║║║║║║║║║║ │ │ └─────┘ └──────┘│ +│ ║ ║║║║║║║║║║ │ │ │ +│ ║ ── aisles ── │ │ ┌────────────────┐│ +│ ║ │ │ │ Storage bay ││ +│ ════loading dock════ │ │ └────────────────┘│ +└─────────────────────┘ └─────────────────────┘ +``` + +**What went wrong:** The new seed produces a *different building*. The office moved. The cargo layout changed. The maintenance corridor is gone. There's a break room that didn't exist before. The result looks like someone demolished the warehouse and built a different facility. It doesn't look like an explosion happened — it looks like a *different world*. + +**Why XOR fails:** XOR produces a uniformly different seed. A uniformly different seed produces a uniformly different Phase 2 output. There's no structural relationship between the original and the result. The template stamper doesn't know this is supposed to be a damaged version of the original — it's just stamping a new template from a new seed. + +### 3.3 Approach B: Structured Damage via Overlay (My Recommendation) + +Don't re-generate. Apply the explosion as a ChunkMutation overlay on the original chunk: + +```rust +// The explosion system computes the damage mask +fn apply_gas_explosion( + chunk: &ChunkData, + center: (u16, u16), + radius: u8, + force: ExplosionForce, +) -> ChunkMutations { + let mut mutations = ChunkMutations::default(); + + for tile in tiles_in_radius(center, radius) { + let distance = distance(center, tile); + let original_tile = chunk.get_tile(tile); + + if distance <= 3 { + // EPICENTER: total destruction + // Walls → rubble. Floor → blast crater. Objects → debris. + mutations.tile_overrides.push(TileOverride { + position: (tile.x, tile.y, tile.z), + new_tile: TileId::BlastCrater, + cause: MutationCause::Explosion { + radius, + source: gas_main_entity, + }, + }); + } else if distance <= 6 { + // INNER BLAST: structural damage + // Walls → breached walls. Objects → destroyed. + // Floor survives but is scorched. + if original_tile.is_wall() { + mutations.structural_changes.push(StructuralChange { + min: (tile.x, tile.y, tile.z), + max: (tile.x, tile.y, tile.z), + change_type: StructuralChangeType::WallDestroyed, + }); + } + mutations.tile_overrides.push(TileOverride { + position: (tile.x, tile.y, tile.z), + new_tile: TileId::ScorchedFloor, + cause: MutationCause::Explosion { + radius, + source: gas_main_entity, + }, + }); + } else if distance <= radius as u16 { + // OUTER BLAST: cosmetic damage + scattered debris + // Walls survive. Objects knocked over. Glass shattered. + if original_tile.has_glass() { + mutations.tile_overrides.push(TileOverride { + position: (tile.x, tile.y, tile.z), + new_tile: TileId::ShatteredGlass, + cause: MutationCause::Explosion { + radius, + source: gas_main_entity, + }, + }); + } + // Scatter debris objects (30% chance per outer tile) + if seeded_chance(tile, event_seed, 0.3) { + mutations.placed_objects.push(PlacedObject { + position: (tile.x, tile.y, tile.z), + object_id: ObjectId::ExplosionDebris, + }); + } + } + } + + mutations +} +``` + +Result: + +``` +AFTER STRUCTURED OVERLAY: + +┌─────────────────────┐ +│ ┌──────┐ │ +│ │Office│ Cargo │ ← Office survives (outside blast radius) +│ │(glass│ racks │ ← Cargo racks: outer ones damaged, inner intact +│ │broke)┘ ▒║║║║║▒ │ +│ ░░░░░ ▒▒▒║║║║▒▒▒ │ ← Maintenance corridor: DESTROYED (epicenter) +│ ████ ▒▒▒▒░░░░▒▒▒ │ ← Inner blast: walls breached, floor scorched +│ ████ ▒▒░░░░░░░▒▒ │ ← Epicenter: blast crater, rubble +│ ████ ▒▒░░████░▒▒ │ ← ████ = rubble/crater (was maintenance corridor) +│ ░░░░ ▒▒▒▒▒▒▒▒▒▒ │ ← ░░ = scorched floor ▒▒ = debris scatter +│ ░░░░ ▒ ▒ │ ← Outer zone: dust and glass +│ ════loading dock════ │ ← Loading dock: partially damaged but recognizable +└─────────────────────┘ + +Legend: ████ = total destruction (blast crater) + ░░░░ = scorched floor (inner blast) + ▒▒▒▒ = debris scatter (outer blast) + ║║║║ = surviving cargo racks +``` + +**What went right:** +1. The warehouse is **recognizable**. The player's spatial memory works — "the office was in the northeast corner, and it's still there, but the glass is shattered." +2. The damage radiates **from the gas main** — the epicenter is the west wall maintenance corridor, which makes physical sense. The player can SEE the cause. +3. The loading dock partially survived — it was far enough from the blast center. The cargo racks nearest the west wall are destroyed; the ones on the east side are intact. The gradient is visible. +4. **New access routes were created:** The maintenance corridor wall is breached. What was a sealed 2-tile pipe run is now a gaping hole. The player can walk through where walls used to be. LOS changed dramatically in the blast zone. +5. **Private geographic knowledge:** The player who was here before the explosion knows the office has a terminal with manifests. The explosion didn't destroy the office — it destroyed the corridor between the office and the loading dock. The player's prior knowledge of the layout gives them an advantage in navigating the damage. + +### 3.4 Mutation Scale Analysis + +How many tile mutations does a gas explosion actually produce? + +| Blast zone | Radius | Tiles affected | Mutations per tile | Total mutations | +|-----------|--------|----------------|-------------------|-----------------| +| Epicenter | 0-3 | ~28 tiles | 1 (total destruction) | ~28 | +| Inner blast | 4-6 | ~65 tiles | 1-2 (wall + floor) | ~100 | +| Outer blast | 7-12 | ~325 tiles | 0.3 average (debris/glass) | ~100 | +| **Total** | | ~418 tiles | | **~228 mutations** | + +228 mutations. Each mutation is ~24 bytes (position + tile ID + cause). Total delta: **~5.5 KB**. This is tiny. It's smaller than the chunk itself. There is no scenario where a single-point explosion overwhelms the overlay model. + +Even a chain explosion that detonates 5 gas mains across a block produces ~1200 mutations (~29 KB). Still trivially small. The overlay model handles this without breaking a sweat. + +### 3.5 When (If Ever) to Use Seed Modification + +XOR re-seeding has exactly one valid use case: **faction-level reconstruction over weeks/months of game time.** + +A district that was destroyed by a massive event and then *rebuilt by the community over months* — the rebuilt version should be different from the original because the rebuilders are making new decisions. This is not damage; this is new construction on a cleared site. + +For this case, the structured approach is: + +```rust +struct DistrictReconstruction { + original_skeleton: DistrictSkeletonRef, + damage_extent: DamageExtent, // which blocks were destroyed + reconstruction_era: Era, // the era of the new construction + reconstruction_heritage: HeritageRoot, // who rebuilt it + reconstruction_seed: u64, // seeded from event + time elapsed +} +``` + +Phase 1 re-runs on the damaged blocks only, with the reconstruction parameters informing the new skeleton. The result is a district where some blocks are original and some are new construction — visually distinct (different era, different condition, different heritage influence), spatially coherent (infrastructure connections preserved), and narratively legible (the player can see where the old meets the new). + +This is a v0.5+ feature. For v0.1–v0.4, the overlay model handles all dynamic modification. + +### 3.6 Verdict + +**XOR re-seeding is rejected** for damage events. It produces uncaused-looking results that violate Ozzie's principle (destruction must feel caused, not random) and Miri's requirement (aftermath must intensify existing character, not replace it). + +**Structured overlay** (ChunkMutations) is the correct approach for all events up to and including full-block destruction. The overlay preserves spatial memory, produces visually causal damage patterns, and creates new gameplay affordances (breached walls, new access routes) that feel earned. + +**Structured reconstruction** (re-running Phase 1 on cleared blocks with reconstruction parameters) is the correct approach for long-term rebuilding after massive events. This is deferred to v0.5+. + +--- + +## 4. DramaDensity NOT on DistrictSkeleton — Confirmed + +All Round 3 participants agree. I'm confirming and documenting the architectural boundary formally. + +### 4.1 Where DramaDensity Lives + +``` +┌─────────────────────────────────────────────────────┐ +│ GENERATOR STATE (immutable after Phase 1) │ +│ │ +│ DistrictSkeleton │ +│ ├── world_tier: WorldTier (static) │ +│ ├── complexity: ComplexityTier (static) │ +│ ├── ... (all spatial/social structure) │ +│ └── NO DramaDensity field │ +│ │ +├─────────────────────────────────────────────────────┤ +│ STORYTELLER STATE (runtime, dynamic, session-scoped) │ +│ │ +│ StorytellerDistrictState │ +│ ├── drama_density: DramaDensity (dynamic) │ +│ ├── active_modules: Vec<ModuleId> (dynamic) │ +│ ├── activated_triangles: Vec<...> (dynamic) │ +│ └── fragility_states: Vec<...> (dynamic) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +### 4.2 The Boundary Rule + +The generator produces **capacity** (what the world CAN support). The storyteller produces **utilization** (what is happening NOW). The DistrictSkeleton is the contract for capacity. DramaDensity is utilization. They live in separate state stores. + +The storyteller reads the DistrictSkeleton to determine: +- What ComplexityTier constrains the DramaDensity ceiling +- Which social sites and triangles exist (the activation targets) +- What spatial configuration enables or limits event placement + +The storyteller writes to its own state: +- DramaDensity (can increase or decrease per session) +- Which modules are active +- Which triangles are fired +- Which fragilities are primed for activation + +**The DistrictSkeleton is never modified by the storyteller.** This is a hard architectural boundary. If the storyteller could write to the skeleton, deterministic re-generation from seed would break. + +### 4.3 Initial DramaDensity + +At the start of a new game, the storyteller reads all DistrictSkeletons and assigns initial DramaDensity values based on: +- The game's seed (deterministic initial drama distribution) +- ComplexityTier ceilings (respecting the constraint matrix) +- Narrative intent (the storyteller's pacing algorithm decides which worlds start hot) + +This initial assignment is stored in StorytellerState, not in the DistrictSkeleton. Same boundary. Even the "initial" drama level is runtime state, not generator state. + +--- + +## 5. FINAL Canonical DistrictSkeleton — D-Record Version + +This is the version that goes into the D-record. All naming resolved. All Round 3 additions included. + +### 5.1 The Struct + +```rust +/// ═══════════════════════════════════════════════════════════ +/// CANONICAL DISTRICTSKELETON — D-RECORD VERSION +/// Generator output for one district. +/// Produced by Phase 1. Consumed by Phase 2. +/// Contract between world prep and local generation. +/// ═══════════════════════════════════════════════════════════ +#[derive(Serialize, Deserialize, Clone, Debug)] +struct DistrictSkeleton { + // ── Identity ────────────────────────────────────────── + /// Unique identifier for this district + district_id: DistrictId, + /// Deterministic seed (derived from master seed via SeedChain) + seed: u64, + /// District classification (logistics hub, residential, mixed-use, etc.) + district_type: DistrictType, + /// World context (which system, which world, neighboring districts) + context: DistrictContext, + + // ── Classification ──────────────────────────────────── + /// Network importance of this world (galaxy-level significance) + world_tier: WorldTier, + /// Generator content budget for this district + complexity: ComplexityTier, + /// Physical setting type (station, urban, agricultural, etc.) + setting: SettingType, + /// Block layout mode (grid for planned, organic for grown) + layout_mode: DistrictLayoutMode, + + // ── Spatial Structure ───────────────────────────────── + /// The 4×4 block grid (each block = 128×128 sim tiles = 2×2 chunks) + blocks: [[BlockSkeleton; 4]; 4], + /// Multi-block reservations (skyscrapers, parks, terminals, plazas) + reservations: Vec<MultiBlockReservation>, + /// Corridor spines connecting key access points + corridors: Vec<CorridorSpine>, + /// Z-level count for this district (practical cap: 64) + z_levels: u8, + + // ── Social Structure ────────────────────────────────── + /// Social site placements with template tags and triangle configs + social_sites: Vec<SocialSitePlacement>, + /// District-level access points (entries/exits to neighboring districts) + access_points: Vec<AccessPoint>, + + // ── Cultural / World Context ────────────────────────── + /// Society profile reference (Miri's cultural ingredients) + society_profile: SocietyProfileRef, + /// Zone palette definitions (base + modifiers per zone) + zone_palette: Vec<ZoneDefinition>, + + // ── Boundary System ─────────────────────────────────── + /// Edge descriptors for transition strips with neighboring districts + boundaries: DistrictBoundaries, + + // ── Validation ──────────────────────────────────────── + /// Guarantee audit result (conditional on complexity tier). + /// None for Empty districts. Tier-appropriate checks for others. + guarantee_audit: Option<GuaranteeAuditResult>, +} +``` + +### 5.2 Supporting Enums and Structs + +```rust +// ═══ WorldTier ═══════════════════════════════════════════ +// (Defined in §2.1 above — not repeated here) + +// ═══ ComplexityTier ══════════════════════════════════════ +/// Generator content budget. Determines which guarantees apply. +/// Derived from WorldTier + SettingType at Phase 1. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +enum ComplexityTier { + /// Full social architecture. All Tier 1+2 guarantees. 20-80+ NPCs. + Full, + /// Moderate social architecture. Tier 1 + partial Tier 2. 8-20 NPCs. + Moderate, + /// Minimal social architecture. Tier 1 only. 1-8 NPCs. + Minimal, + /// No social architecture. Pure terrain. 0 NPCs. No guarantees. + Empty, +} + +// ═══ SettingType ═════════════════════════════════════════ +/// Physical setting. Merged from Gestalt's SettingGeometry + Tyre's TerrainType. +#[derive(Serialize, Deserialize, Clone, Debug)] +enum SettingType { + Station, + Urban, + Agricultural, + Maritime, + Wilderness { biome: Biome }, + Water { water_type: WaterType }, + Transitional, + Orbital, + Specialized { function: SpecializedFunction }, +} + +// ═══ DistrictLayoutMode ═════════════════════════════════ +/// How blocks are placed within the district's 512×512 footprint. +#[derive(Serialize, Deserialize, Clone, Debug)] +enum DistrictLayoutMode { + /// Standard Cartesian grid. Perpendicular streets. + Grid, + /// Organic placement with per-block offsets and rotations. + Organic { + placements: [[BlockPlacement; 4]; 4], + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct BlockPlacement { + /// Offset from grid-aligned position (±16 sim tiles per axis max) + offset: (i16, i16), + /// Rotation in 15° increments (0-3, max 45°) + rotation_steps: u8, + /// Street width multiplier (0.75–2.0, default 1.0 = 4 visual tiles) + street_width_factor: f32, +} + +// ═══ BlockSkeleton ══════════════════════════════════════ +#[derive(Serialize, Deserialize, Clone, Debug)] +struct BlockSkeleton { + position: (u8, u8), + zoning: ZoningType, + reservation: Option<ReservationId>, + chunk_layout: ChunkLayout, + hosted_sites: Vec<SocialSiteId>, + era: Era, + era_modifications: Vec<EraModification>, + era_cause: Option<EraCause>, + density: f32, + landmark: Option<LandmarkSlot>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum EraCause { + Original, + CorporateMerger, + EmergencyExtension, + OrganicGrowth, + InstitutionalIncursion, + EconomicDisruption, + CulturalShift, +} + +// ═══ MultiBlockReservation ══════════════════════════════ +#[derive(Serialize, Deserialize, Clone, Debug)] +struct MultiBlockReservation { + blocks: Vec<(u8, u8)>, + template_tag: String, + function: ReservationFunction, + z_levels: u8, + base_z: u8, + floor_zones: Vec<FloorZone>, + z_band_count: u8, + z_band_zones: Vec<ZoneDefinition>, + vertical_corridors: Vec<VerticalCorridorSpec>, + hosted_sites: Vec<SocialSiteId>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct FloorZone { + z_level: u8, + zone_type: ZoningType, + zone_palette: ZonePalette, + access_tier: AccessTier, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct VerticalCorridorSpec { + block_coords: Vec<(u8, u8)>, + z_bands_connected: Vec<u8>, + access_tier: AccessTier, + corridor_type: VerticalCorridorType, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum ZLevelLoadState { + Loaded(ChunkData), + Skeleton(FloorZone), + Ungenerated, +} + +// ═══ Social Structure ═══════════════════════════════════ +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SocialSitePlacement { + site_id: SocialSiteId, + blocks: Vec<(u8, u8)>, + template_tag: String, + access_tier: AccessTier, + triangles: Vec<TriangleAssignment>, + role_slots: Vec<RoleSlot>, + active_phases: Vec<DayPhase>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct TriangleAssignment { + template: TriangleTemplate, + purposes: Vec<TrianglePurpose>, + participants: Vec<RoleSlotId>, + staging_block: Option<(u8, u8)>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +enum TrianglePurpose { + Investigation, + Economic, + Social, + Political, + Tactical, + Mundane, +} + +// ═══ Palette System ═════════════════════════════════════ +#[derive(Serialize, Deserialize, Clone, Debug)] +struct ZonePalette { + base: BasePalette, + modifiers: Vec<PaletteModifier>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum PaletteModifier { + EconomicFunction(EconomicModifier), + Era(Era), + FactionPresence(FactionModifier), + Condition(ConditionModifier), + Heritage(HeritageRoot), + Season(Season), +} + +// ═══ Destructible Boundaries ════════════════════════════ +/// What exists on the far side of a wall tile. +/// Every wall tile in a generated chunk is tagged with one of these. +#[derive(Serialize, Deserialize, Clone, Debug)] +enum WallBackside { + /// Another room/corridor exists (tiles already generated). + AdjacentSpace, + /// Solid structural material. 1-2 tiles of fill, then another wall. + StructuralFill, + /// Narrow utility gap (1-3 tiles). Pipes/conduits. Non-navigable. + /// Supports modified LOS and small object passing. + ServiceVoid, + /// Edge of chunk. Adjacent chunk's boundary tiles on the far side. + ChunkBoundary, + /// Faces outside (hull, exterior wall). Breach = catastrophic consequences. + Exterior, +} + +// ═══ Dynamic Modification ═══════════════════════════════ +/// Mutations applied to an already-generated chunk. +/// Stored alongside the chunk in the save file. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +struct ChunkMutations { + tile_overrides: Vec<TileOverride>, + structural_changes: Vec<StructuralChange>, + placed_objects: Vec<PlacedObject>, + removed_objects: Vec<ObjectId>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct TileOverride { + position: (u16, u16, u8), + new_tile: TileId, + cause: MutationCause, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum MutationCause { + Explosion { radius: u8, source: EntityId }, + Fire { spread_from: Option<(u16, u16)> }, + Construction { builder: EntityId }, + Decay { time_since_maintenance: u32 }, + PlayerAction { action: ActionId }, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct StructuralChange { + min: (u16, u16, u8), + max: (u16, u16, u8), + change_type: StructuralChangeType, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum StructuralChangeType { + WallDestroyed, + FloorCollapsed, + CeilingBreached, + AreaSealed, + WallConstructed, +} + +// ═══ Guarantee Audit ════════════════════════════════════ +/// Conditional guarantee audit. Tier-aware: Minimal districts +/// get 3 checks; Full districts get up to 11. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct GuaranteeAuditResult { + // Tier 1: Universal (all inhabited districts) + social_hub: AuditCheck, + informal_zone: AuditCheck, + encounter_corridor: AuditCheck, + + // Tier 2: Full-complexity only (None for lower tiers) + traffic_chokepoint: Option<AuditCheck>, + institutional_space: Option<AuditCheck>, + insider_space: Option<AuditCheck>, + economic_node: Option<AuditCheck>, + + // Tier 3: Conditional (None unless conditions met) + elevated_vantage: Option<AuditCheck>, + temporal_opacity_window: Option<AuditCheck>, + power_gradient_visibility: Option<AuditCheck>, + economic_asymmetry_signal: Option<AuditCheck>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct AuditCheck { + passed: bool, + satisfied_by: Option<SatisfiedBy>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum SatisfiedBy { + SocialSite(SocialSiteId), + Block(u8, u8), + Corridor(CorridorSpineId), + TerrainFeature(String), +} +``` + +### 5.3 Updated Memory Budget + +With all Round 3 + Round 4 changes: + +| Component | Size per district | Notes | +|-----------|------------------|-------| +| Identity + classification | ~80 bytes | Fixed; WorldTier replaces SignificanceTier | +| Blocks (4×4 × BlockSkeleton) | ~2 KB | era_cause, density, landmark fields | +| Social sites + triangles | ~1-4 KB | Scales with ComplexityTier | +| Reservations + corridors | ~0.5-2 KB | Skyscraper reservations larger | +| Boundaries | ~4 KB | 4 edges × ~1 KB | +| Society profile ref | ~32 bytes | Reference only | +| Zone palette (with modifiers) | ~0.5-1 KB | Modifier stacks add ~50 bytes each | +| Guarantee audit | ~300 bytes | Now Option<> on Tier 2/3 checks | +| Layout mode (organic) | 0-1 KB | Only for organic districts | +| **Total per district** | **~8-14 KB** | Unchanged from Round 3 | + +**MobileChunk memory:** + +| Component | Size per vessel | Notes | +|-----------|----------------|-------| +| Identity + vessel class | ~96 bytes | Fixed | +| ChunkData (when loaded) | ~16 KB | Standard 64×64 chunk | +| Social sites + triangles | ~0.2-1 KB | Simpler than district | +| Crew + passengers | ~0.5-2 KB | Roster references | +| Mutations | ~0-1 KB | Accumulates over voyages | +| **Total per vessel (loaded)** | **~17-20 KB** | | +| **Total per vessel (unloaded)** | **~1-4 KB** | ChunkData not in memory | + +**System totals:** + +| Scope | Count | Memory | +|-------|-------|--------| +| All district skeletons | 300 worlds × ~6 districts | ~21 MB | +| Active vessels (loaded) | ~5-10 in player vicinity | ~100-200 KB | +| All vessels (unloaded) | ~50-200 across all systems | ~200-800 KB | +| **Total generator state** | | **~22 MB** | + +Trivial. Well within any platform's memory budget. + +### 5.4 The MobileChunk as Companion Struct + +The MobileChunk is NOT a DistrictSkeleton variant. It's a companion struct — a first-class world entity that uses the same ChunkData format but has its own generation and streaming rules. + +``` +DistrictSkeleton (static world) MobileChunk (mobile world) +├── 4×4 blocks ├── Single chunk (or 2×2 for Capital) +├── Full Phase 1 → Phase 2 ├── Template stamp only (no skeleton) +├── Spatial guarantees ├── No spatial guarantees +├── Complex NPC generation ├── Roster seeding (crew + passengers) +├── Multiple ComplexityTiers ├── VesselClass instead +├── Fixed world coordinates ├── Entity-attached coordinates +└── Immutable after gen └── Immutable after gen (mutations layer) + + movement state (dynamic) +``` + +Both produce `ChunkData`. Both support `ChunkMutations`. Both feed the same rendering pipeline. The difference is in generation complexity and world-presence model. + +--- + +## 6. Updated Cost Summary + +Round 4 changes to the cost estimate: + +| Change | Impact | +|--------|--------| +| XOR re-seeding removed | −0 dev-days (was never costed separately) | +| Structured reconstruction (v0.5+) | +3 dev-days (deferred, not in v0.1-v0.4 total) | +| MobileChunk Idle state added | +0.5 dev-days | +| WorldTier rename | 0 dev-days (nominal change) | + +**Revised total: ~54.5 dev-days (v0.1–v0.4)** — essentially unchanged from Round 3. + +| Milestone | Dev-Days | What Ships | +|-----------|----------|-----------| +| **v0.1** | ~7 | DistrictSkeleton stub, BlockPlan stub, Transit District validation fixture | +| **v0.2** | ~10.5 | SeedChain, SocietyProfile, DistrictSkeleton impl, Phase 2 chunk fill, ChunkMutations struct | +| **v0.3** | ~23 | Edge bleed, palette modifiers, organic layout, mobile chunks (docked+transit+idle), mutation rendering, wall backside, lazy z-levels, Phase 1 background thread | +| **v0.4** | ~14 | Skyscrapers, explosion mutations, non-urban terrain, NPC reduced-fidelity floors | + +--- + +## 7. Summary: What Round 4 Resolved + +| OQ | Resolution | Status | +|----|-----------|--------| +| OQ-R4-A: Vessel Architecture | MobileChunk (entity-carried) is FINAL. Vessel interiors are simplified (VesselClass, template-stamp, no spatial guarantees). Persistent docked vessels are the key differentiator. Cost delta vs instanced: +3 dev-days. | **RESOLVED** | +| OQ-R4-B: WorldTier Rename | SignificanceTier → WorldTier. Five tiers: Epicenter, Regional, Passage, Backwater, Waypoint. Constraint matrix defined. | **RESOLVED** | +| OQ-R4-F: XOR Re-Seeding | **REJECTED.** XOR produces uncaused-looking results. Structured overlay (ChunkMutations) for all damage events. A gas explosion produces ~228 mutations (~5.5 KB) — trivially within the overlay model's budget. Structured reconstruction for long-term rebuilding deferred to v0.5+. | **RESOLVED** | +| DramaDensity placement | Confirmed NOT on DistrictSkeleton. Lives in StorytellerDistrictState. Hard architectural boundary: generator produces capacity, storyteller produces utilization. | **CONFIRMED** | +| Canonical DistrictSkeleton | FINAL version with all naming resolved (WorldTier, DistrictLayoutMode, SettingType, all Round 3 additions). MobileChunk as companion struct. Memory budget: ~22 MB total. Ready for D-record. | **FINAL** | + +--- + +*Tyre — Round 4 complete. The DistrictSkeleton is canonical and ready for the D-record. MobileChunk is specified with Nigel's simplicity concern resolved. WorldTier is named. XOR is dead; overlays are the way. DramaDensity knows its place: runtime, not generator. The architecture is final. ~54.5 dev-days, v0.1–v0.4. Let's build it.* diff --git a/docs/workshops/generator-architecture/tyre-round5.md b/docs/workshops/generator-architecture/tyre-round5.md new file mode 100644 index 000000000..3633fe92c --- /dev/null +++ b/docs/workshops/generator-architecture/tyre-round5.md @@ -0,0 +1,176 @@ +# Round 5: Tyre — Final Review of Workshop Outcomes + +**Workshop:** Generator Architecture (#562) +**Agent:** Tyre (Technical Architect) +**Date:** 2026-02-27 + +**Round 5 scope:** Review `workshop-outcomes.md` for accuracy against my Round 4 canonical output. Corrections only. + +--- + +## Overall Assessment + +The outcomes document is **accurate and well-compiled**. Qatux has correctly captured the three-layer model, the spatial hierarchy, the Phase 1 pipeline, and all 14 D-READY items. The lead decisions table is correct. The key tensions and resolutions table is correct. The open questions are correctly identified. + +*cracks knuckles* — That said, I have a few corrections. None are architectural — they're all accuracy/consistency issues in how the outcomes doc represents decisions that were made. + +--- + +## Corrections + +### CORRECTION 1: WorldTier enum values don't match Round 4 canonical + +**Location:** Workshop-outcomes §"WorldTier and ComplexityTier" (lines 127–143) + +**Issue:** The outcomes document uses: +``` +Core, Regional, Local, Transit, Dormant +``` + +My Round 4 canonical enum (§2.1, the D-record version) uses: +``` +Epicenter, Regional, Passage, Backwater, Waypoint +``` + +The lead decision L-3 says "WorldTier wins over SignificanceTier as the field name." Correct — but the **enum variant names** should match the Round 4 canonical. The outcomes doc appears to have substituted simplified names that were never agreed upon. + +**Fix:** Replace the WorldTier enum values with the Round 4 canonical: + +| Current (incorrect) | Correct (Round 4 canonical) | +|---------------------|---------------------------| +| Core | Epicenter | +| Regional | Regional | +| Local | Backwater | +| Transit | Passage | +| Dormant | Waypoint | + +Also update the constraint ceiling text: +- Current: "Core/Regional → Full max; Local → Moderate max; Transit → Minimal; Dormant → Empty" +- Correct: "Epicenter/Regional → Full max; Backwater → Full (key insight: dense isolated community); Passage → Moderate max; Waypoint → Minimal" + +This matters because the Backwater → Full case is one of the most important combinations in the game (§2.3 of my Round 4 — the fishing village scenario). The current text says "Local → Moderate max" which would **prohibit** this case. That's a material error. + +### CORRECTION 2: Missing fields on DistrictSkeleton summary + +**Location:** Workshop-outcomes §"The Three-Layer Model" (lines 43–79) + +**Issue:** The canonical DistrictSkeleton in my Round 4 (§5.1) includes fields that are absent from the outcomes summary: + +- `district_id: DistrictId` — identity field, omitted +- `seed: u64` — critical for deterministic generation, omitted +- `district_type: DistrictType` — classification, omitted +- `context: DistrictContext` — world context, omitted +- `access_points: Vec<AccessPoint>` — district-level entries/exits, omitted + +The summary does include `breach_only_zones`, `vertical_structure`, and `derived_analysis` which are **not** on my Round 4 canonical struct. These appear to have been pulled from other participants' proposals rather than the final canonical struct. + +**Fix:** The DistrictSkeleton field list in the three-layer model should match the canonical struct from my Round 4 §5.1. Either reproduce it exactly or add a note that the summary is simplified and the full struct is in the D-record. + +I'd recommend: keep the summary form but correct the field list to match the canonical. Add the missing identity/seed/context fields. Remove `breach_only_zones`, `vertical_structure`, and `derived_analysis` unless another participant's round 4 added these and I missed them — in which case, note the source. + +### CORRECTION 3: Spatial hierarchy table — missing visual tile dimension + +**Location:** Workshop-outcomes §"Spatial Hierarchy (D-094)" (lines 83–87) + +**Issue:** Minor. The table lists "Visual tiles" as 32×32 for chunks, 64×64 for blocks, 256×256 for districts. These are correct. But my Round 4 also specifies that a Block is "2×2 chunks" in the DistrictSkeleton comments (§5.1, line: `/// The 4×4 block grid (each block = 128×128 sim tiles = 2×2 chunks)`). The outcomes table says Block = 4 chunks in the "Purpose" column. These are consistent (2×2 = 4 chunks total). No error — just confirming. + +**No fix needed.** The table is correct. + +### CORRECTION 4: MobileChunk — missing `Idle` movement state + +**Location:** Workshop-outcomes §D-READY-13 (lines 334–346) + +**Issue:** The outcomes document lists `MobileMovementState` as `(Docked / InTransit / InterSystem)`. My Round 4 canonical (§1.4) includes a fourth state: **`Idle`** — vessel parked at a location but not docked to infrastructure (anchored ship, grounded shuttle). This was a Round 4 addition (noted in my §6 cost summary: "MobileChunk Idle state added: +0.5 dev-days"). + +**Fix:** Add `Idle` to the MobileMovementState list: +``` +MobileMovementState (Docked / InTransit / InterSystem / Idle) +``` + +### CORRECTION 5: Implementation targets — MobileChunk effort + +**Location:** Workshop-outcomes §"Implementation Targets" (lines 409–416) + +**Issue:** The outcomes document says "MobileChunk (single-chunk vessels)" at v0.3, ~9.5 dev-days. This is consistent with my Round 4 estimate. However, my Round 4 milestone breakdown (§6) places mobile chunks at **v0.3** alongside organic layout, palette modifiers, edge bleed, etc. — the v0.3 total is ~23 dev-days. The outcomes table correctly shows ~9.5 for just the MobileChunk portion. + +**No fix needed.** The figure is correct. + +### CORRECTION 5b: MobileChunk `Docked` state missing required fields + +**Location:** Workshop-outcomes §D-READY-13 (lines 340–341) + +**Issue:** D-READY-13 explicitly states: +> "The `scheduled_departure: Option<SimTick>` field in `Docked` state satisfies this; the generator must populate it. Vessels without departure schedules are an error state." + +My Round 4 canonical `Docked` state (§1.4): +```rust +Docked { + dock_position: WorldPosition, + connected_chunk: Option<ChunkCoord>, +} +``` + +Neither `scheduled_departure` nor `docked_since` is present. The outcomes doc explicitly references these fields as satisfying a generator requirement — so the D-record struct must include them. The outcomes doc is correct; my Round 4 struct is incomplete here. + +**Fix for D-record filing:** +```rust +Docked { + dock_position: WorldPosition, + connected_chunk: Option<ChunkCoord>, + docked_since: SimTick, + scheduled_departure: Option<SimTick>, +}, +``` + +--- + +### CORRECTION 6: Guarantee audit — missing checks from Round 4 + +**Location:** Workshop-outcomes §D-READY-2 (lines 158–165) and §D-READY-8 (lines 249–257) + +**Issue:** My Round 4 `GuaranteeAuditResult` struct (§5.2) has 11 named checks. D-READY-2 in the outcomes lists the tiers correctly. However, D-READY-8 says "A-1/A-2/A-3 are Tier 3 Conditional" and "A-4 is mandatory Full-complexity." In my Round 4 audit struct, A-4 (`non_institutional_route`) is NOT in the struct — it's listed in D-READY-8 as a requirement but I didn't add it as a named field on `GuaranteeAuditResult`. + +**This is my omission from Round 4**, not an error in the outcomes doc. The outcomes correctly state A-4 is mandatory for Full-complexity. When filing the D-record, A-4 should be added to the `GuaranteeAuditResult` struct as a Tier 2 check (it's mandatory for Full, not conditional). Similarly, `egress_multiplicity` (A-2) is missing from my struct. + +**Recommended fix for D-record filing (not outcomes doc):** Add `non_institutional_route: Option<AuditCheck>` and `egress_multiplicity: Option<AuditCheck>` to the Tier 2 section of `GuaranteeAuditResult`. Also add `horizon_view_corridor: Option<AuditCheck>` and `breach_only_zone: Option<AuditCheck>` and `rooftop_discovery: Option<AuditCheck>` from D-READY-2 Tier 2 list. The outcomes doc correctly lists these; my Round 4 struct was incomplete. + +--- + +## Items Verified Correct (No Changes Needed) + +- **L-1 through L-7:** All lead decisions accurately captured. +- **Phase 1 pipeline stages:** Correct (5 stages). +- **LayoutMode struct:** `BlockPlacement` with offset, rotation_steps, street_width_factor — matches Round 4. +- **45-degree rotation hard cap:** Correctly stated as non-negotiable. +- **D-READY-3 (TrianglePurpose):** Enum values match. `Vec<TrianglePurpose>` correctly noted. +- **D-READY-4 (WallBackside/TileBehindState):** Dual classification correctly captured. Era-tagged infrastructure contents correct. +- **D-READY-5 (DamageOverlay):** Structs match. `scatter_seed` correctly scoped. `RegenerationStrategy` correctly defined. XOR prohibition correctly stated. +- **D-READY-6 (ZonePalette):** 8 base terrain types correct. Modifier axes (A/B/C) correctly described. +- **D-READY-9 (Heritage Grammar):** Blend rules correct. Authoring domain separation (Miri vs Araminta) correct. +- **D-READY-12 (Trauma Events):** `TraumaEventSubtype` enum matches. Decay rate per heritage root correct. +- **D-READY-14 (DamageOverlay/RegenerationStrategy):** Correctly filed as separate D-record from D-READY-5. Prohibition framing accurate. +- **NPC model (10 axes):** All axes correctly listed. Axis 11 correctly flagged as Q-record. +- **Minimum NPC count (3):** Correctly stated. +- **Key tensions table:** All 6 tensions and resolutions accurate. +- **Open questions:** All 6 Q-records correctly identified with appropriate owners. +- **Ozzie's "promises" synthesis:** Accurately captured. + +--- + +## Summary of Required Corrections + +| # | Severity | What | Where | +|---|----------|------|-------| +| 1 | **HIGH** | WorldTier enum values wrong (Core/Local/Transit/Dormant → Epicenter/Backwater/Passage/Waypoint). Constraint ceiling text prohibits the Backwater+Full case. | §WorldTier, lines 127–143 | +| 2 | MEDIUM | DistrictSkeleton field list has missing and extra fields vs. Round 4 canonical | §Three-Layer Model, lines 43–62 | +| 3 | — | No correction needed | §Spatial Hierarchy | +| 4 | LOW | MobileMovementState missing `Idle` variant | §D-READY-13, line 336 | +| 5 | — | No correction needed | §Implementation Targets | +| 5b | MEDIUM | `Docked` state missing `scheduled_departure` + `docked_since` (D-READY-13 explicitly requires them) | §D-READY-13, line 340 | +| 6 | NOTE | My Round 4 GuaranteeAuditResult struct was incomplete — outcomes doc is correct, but D-record filing should add missing Tier 2/3 fields | §D-READY-2/8 | + +**Correction 1 is the only one I'd flag as potentially misleading** — the wrong enum values combined with the wrong constraint ceiling text could cause implementation to prohibit Backwater+Full, which is a key game experience. The rest are bookkeeping. + +--- + +*Tyre — Round 5 review complete. One high-severity correction (WorldTier enum names and constraint ceiling), one medium (DistrictSkeleton field list), one low (missing Idle state). The outcomes document is solid work overall — the architecture is accurately represented.* diff --git a/docs/workshops/generator-architecture/workshop-brief.md b/docs/workshops/generator-architecture/workshop-brief.md new file mode 100644 index 000000000..c0fe4c87e --- /dev/null +++ b/docs/workshops/generator-architecture/workshop-brief.md @@ -0,0 +1,142 @@ +# Generator Architecture Workshop Brief + +**Goal:** Establish the top-down procedural generator pipeline architecture — from geography down to individual chunk fill — that will power the 300-world model. Produce a D-record defining generator primitives, the spatial hierarchy, and the sub-chunk building system. +**Ticket:** #562 (story, parent: #50 Chunk-based Map System) +**Priority:** MEDIUM — prerequisite to #144 (Chunk generation system), Sprint 21+ target +**Participants:** Gestalt (systems design), Tyre (architecture), Miri (worldbuilding), Araminta (spatial/visual), Nigel (replayability/procedural gen), Qatux (docs), SI (tickets) +**Source:** Station District Layout Workshop (#153, Round 3), lead directive + +--- + +## Context + +The v0.1 Transit District is hand-authored. The long-game goal is 300 procedurally generated worlds. The question this workshop must answer is: **what is the generator's architecture — top to bottom?** + +The lead has identified a top-down pipeline model inspired by Cities Skylines: + +``` +Geography + → Infrastructure (transport nodes, utilities) + → Amenities & Services + → Population (extrapolated from capacity) + → Zoning + → Block generation + → Chunk fill (individual buildings and spaces) +``` + +This workshop also addresses a related structural question: how does the **sub-chunk quarter system** provide building variety within template-driven generation? A chunk divides into 4 quarters that can merge, split, leave gaps, or host shacks/gardens — producing L-shapes, mixed-use footprints, and irregular structures without breaking the generator's regularity. + +Multi-block structures (train stations, government buildings, stadiums, parks, farmland) span multiple chunks and must be accounted for in the block and zoning passes before individual chunks are filled. + +**Starting point:** Q-036 asks whether the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) is the atomic generator output unit. D-025 defines social sites as atomic template units for hand-authoring. This workshop must reconcile the two. + +**What is already decided:** +- D-025: Social site / functional cluster as atomic template unit (hand-authoring) +- D-036: Sova Transit District as v0.1 setting (hand-authored) +- D-012: Chunk-based map system (bounded for v0.1, borderless-capable) +- #153 D-record (Station District Layout Workshop): district/block/chunk spatial hierarchy, spatial dimensions, access topology gradient — reference `decisions/content.md` for the confirmed record + +**What is still open:** +- Q-036: District skeleton as generator output (assigned Tyre, Gestalt) +- Q-037: Generator development pipeline / phased production model (assigned SI, Tyre) +- Q-039: Procedural gate topology generation + +--- + +## Key Questions to Resolve + +### 1. Pipeline Architecture + +1. Is the Cities Skylines top-down model (geography → zoning → chunk fill) the right architecture for The Settled Reach, or does the game's station-centric setting require a different ordering? +2. What is the correct sequence — does population follow zoning, or precede it? +3. How does the pipeline handle stations (Sova) vs. planet-side cities vs. orbital installations? Same pipeline, different geography inputs? +4. Where do political/economic conditions enter the pipeline? (Faction control, prosperity tier, trade routes) +5. At what pipeline stage are the triangle templates (D-025) instantiated? + +### 2. Spatial Hierarchy and Primitives + +6. What are the canonical spatial units in the hierarchy? (Region → District → Block → Chunk → Sub-chunk quarter? Or different names/levels?) +7. What is the confirmed chunk size in sim tiles? (Reference #153 D-record — the district workshop decided this) +8. What is the block size — how many chunks per block? +9. What is the district size — how many blocks per district? +10. How does the sub-chunk quarter system work mechanically? (Quarter = ¼ chunk, can merge 2×2, 1×2, L-shape. What are the merge rules? Who decides fill vs. empty?) + +### 3. Multi-Block Structures + +11. How are multi-block structures (train stations, stadiums, government complexes, parks) represented in the generator? (Reserved footprint at the zoning pass? Pre-baked templates that claim N×M blocks?) +12. What is the maximum multi-block footprint — is there a cap? +13. How do multi-block structures interact with neighbouring chunk fills at their edges? +14. Can a multi-block structure span district boundaries? + +### 4. District Skeleton as Generator Output (Q-036) + +15. Is the district skeleton (social site arrangement, NPC slot allocation, triangle template selection, access topology) the correct atomic output of the district-generation stage? +16. How does the district skeleton output interact with D-025's social site templates? (Generator selects and arranges templates, not individual tiles?) +17. What inputs does the district skeleton generator consume? (Zoning type, population density, faction control, economic function, transport adjacency) +18. What does the district skeleton output look like as a data structure? (List of social site slots with positions, access tier, NPC capacity, template tag) + +### 5. Replayability and Variation + +19. What variation levers exist at each pipeline stage? (Seed, faction weights, economic tier, historical events?) +20. How does the sub-chunk quarter system produce perceived variety across multiple playthroughs? +21. How are "flavour" structures (shacks, gardens, market stalls) assigned to unclaimed quarter space? +22. What prevents two generated districts from feeling identical even if they share the same zoning type? + +### 6. v0.1 / Generator Boundary + +23. Where does the v0.1 hand-authored content end and the generator begin? What stub interfaces must v0.1 leave behind? +24. Which pipeline stages are in scope for implementation, and which are deferred (per Q-037's phased production model)? +25. Does the v0.1 Transit District need to be expressible as generator output (for validation), or is it purely an authored ground-truth? + +--- + +## Input Documents + +| Document | What to read | Why | +|----------|-------------|-----| +| `decisions/content.md` | D-025 (social site template), #153 D-record | Generator must compose from these primitives | +| `decisions/scope.md` | D-012 (chunk system), D-036 (Sova setting) | Spatial constraints and v0.1 setting | +| `decisions/questions.md` | Q-036 (district skeleton), Q-037 (generator pipeline), Q-039 (gate topology) | Open questions this workshop resolves | +| `docs/design/sova-station-profile.md` | District types, 6-district layout | The worldbuilding context the generator must reproduce | +| `docs/design/spatial-layout-terminal-v01.md` | Terminal layout | Example of a hand-authored chunk cluster | +| `docs/design/spatial-layout-bar-v01.md` | Bar layout | Example of a hand-authored chunk cluster | +| `decisions/architecture.md` | D-014 (tile-based movement), D-012 (chunk loading) | Technical constraints on spatial units | +| `docs/workshops/content-architecture/content-architecture-workshop-brief.md` | Three-tier content pipeline | How templates and procedural generation already relate | + +--- + +## Expected Outputs + +1. **D-record: Generator Architecture** — confirmed in `decisions/architecture.md`: + - Top-down pipeline stages (named and sequenced) + - Spatial hierarchy (named levels, tile dimensions per level) + - Sub-chunk quarter system rules (merge/split/fill logic) + - Multi-block structure reservation protocol + - District skeleton as generator output: data structure definition + - v0.1 / generator boundary (what's hand-authored, what's stubbed) + +2. **Resolution of Q-036:** District skeleton as atomic generator output — yes/no + formal definition if yes + +3. **Resolution of Q-037 scope:** Which pipeline phases land in which version window (v0.1 stub, v0.2–0.5 template expansion, v0.6–0.10 generator development) + +4. **Tickets:** Implementation tasks derived from the D-record (chunk data structure update, district skeleton schema, zoning pass stub, block generation stub) + +--- + +## Workshop Format + +Three rounds: + +**Round 1 — Domain Inventory** +Each participant reviews existing decisions and states what their domain requires from the generator architecture. +- Gestalt: what gameplay loops does the generator need to support? What must it guarantee (e.g., always a surveillance chokepoint, always a quiet zone)? +- Tyre: what are the hard technical constraints on chunk size, hierarchy depth, and data structure for the district skeleton? +- Miri: how does the generator reproduce the cultural/economic variation of 300 worlds? What lore-level inputs drive the pipeline? +- Araminta: what visual coherence constraints does chunk fill need to satisfy? How does the sub-chunk quarter system produce plausible streetscapes? +- Nigel: what variation and replayability guarantees must the generator provide? What makes two generated districts feel different? + +**Round 2 — Pipeline Proposals** +Propose concrete pipeline architecture. Name the stages, define the spatial hierarchy levels with tile dimensions, describe the district skeleton data structure. Respond to each other's domain requirements from Round 1. + +**Round 3 — Convergence** +Resolve conflicts, agree on the pipeline sequence, lock spatial hierarchy dimensions, define the district skeleton output format, set the v0.1/generator boundary. Draft the D-record. diff --git a/docs/workshops/generator-architecture/workshop-outcomes.md b/docs/workshops/generator-architecture/workshop-outcomes.md new file mode 100644 index 000000000..f74a0cae8 --- /dev/null +++ b/docs/workshops/generator-architecture/workshop-outcomes.md @@ -0,0 +1,470 @@ +# Generator Architecture Workshop — Outcomes + +**Workshop:** Generator Architecture (#562) +**Rounds:** 1 through 4 +**Dates:** 2026-02-27 +**Participants:** Gestalt, Tyre, Miri, Araminta, Nigel, Ozzie +**Compiled by:** Qatux + +--- + +## Purpose + +This document is the authoritative summary of the Generator Architecture workshop. It compiles all confirmed decisions, the canonical data structures, the D-record inventory, and the open questions remaining for sprint work. + +The source documents are the four round notes files: +- `docs/workshops/generator-architecture/round-1-notes.md` +- `docs/workshops/generator-architecture/round-2-notes.md` +- `docs/workshops/generator-architecture/round-3-notes.md` +- `docs/workshops/generator-architecture/round-4-notes.md` + +--- + +## Lead Decisions + +These are decisions made or confirmed by the project lead (Jeroen) and are not subject to further team debate. + +| # | Decision | Round confirmed | +|---|----------|-----------------| +| L-1 | The generator uses a **two-phase** architecture: Phase 1 (DistrictSkeleton, async background) and Phase 2 (ChunkData on-demand per chunk). | R2 | +| L-2 | Both **Grid and Organic** layout modes exist. The lead mandated "some blocks grid, some organic chaos." | R3 | +| L-3 | **WorldTier** wins over SignificanceTier as the field name on DistrictSkeleton. | R4 | +| L-4 | **Entity-carried MobileChunk** is core architecture. Vessels are persistent world entities with a Docked state. | R4 | +| L-5 | **DramaDensity** is runtime storyteller state. It does NOT appear on DistrictSkeleton. | R4 | +| L-6 | **Heritage grammar overlay** is base game content, not DLC. | R4 | +| L-7 | **XOR reseeding for in-playthrough events is prohibited.** `DamageOverlay` is the correct approach for all player-witnessed structural events. | R4 (unanimously confirmed) | + +--- + +## Confirmed Architecture — Summary + +### The Three-Layer Model + +``` +GENERATOR STATE (immutable after Phase 1) +├── Phase 1: DistrictSkeleton +│ ├── district_id: DistrictId (identity) +│ ├── seed: u64 (deterministic generation) +│ ├── district_type: DistrictType (classification) +│ ├── context: DistrictContext (world context) +│ ├── world_tier: WorldTier (simulation fidelity budget) +│ ├── complexity_tier: ComplexityTier (content budget) +│ ├── layout_mode: DistrictLayoutMode (Grid | Organic) +│ ├── setting: SettingType (terrain + environment type) +│ ├── blocks: [[BlockSkeleton; 4]; 4] (4×4 block grid) +│ ├── reservations: Vec<MultiBlockReservation> +│ ├── corridors: Vec<CorridorSpine> +│ ├── z_levels: u8 +│ ├── vertical_structure: VerticalStructure (source: multi-participant) +│ ├── breach_only_zones: Vec<ZoneId> (source: multi-participant) +│ ├── social_sites: Vec<SocialSitePlacement> +│ ├── society_profile: SocietyProfileRef +│ ├── zone_palette: Vec<ZoneDefinition> +│ ├── boundaries: DistrictBoundaries +│ ├── access_points: Vec<AccessPoint> (district entries/exits) +│ ├── guarantee_audit: GuaranteeAuditResult +│ └── derived_analysis: DerivedDistrictAnalysis (source: Miri/Gestalt; Phase 1 computed) +└── Phase 2: PreparedDistrict (on-demand per chunk) + ├── SocialSitePlacement (triangles with Vec<TrianglePurpose>) + ├── NpcManifest (seeded from society_profile) + ├── ZonePalette assignments (base + heritage modifiers) + └── ChunkMutations pending + +SIMULATION STATE (runtime storyteller — NOT generator output) +├── DistrictRuntimeState.drama_density: DramaDensity +├── active_triangles: Vec<TriangleId> +├── npc_pattern_weights: NpcPatternWeightSet +└── assassination_difficulty on-demand computation + +DELTA LAYER (post-generation) +├── DamageOverlay (LocalOverlay for in-playthrough events) +├── NpcRemoved / NpcStateChanged +├── AccessTierChanged +└── WorldStateDelta (composed from all active mutations) +``` + +### Spatial Hierarchy (D-094) + +| Unit | Sim tiles | Visual tiles | Real meters | Purpose | +|------|-----------|--------------|-------------|---------| +| Chunk | 64×64 | 32×32 | 32m | Streaming unit | +| Block | 128×128 | 64×64 | 64m | Generator planning unit (4 chunks) | +| District | 512×512 | 256×256 | 256m | Simulation unit (4×4 blocks) | + +### Phase 1 Generator Pipeline + +``` +Pre-Pipeline: system generation, WorldTier assignment, galaxy topology + ↓ +Phase 1: DistrictSkeleton + Stage 1: Classification (WorldTier, ComplexityTier, SettingType) + Stage 2: Block grid (DistrictLayoutMode, BlockSkeleton ×16) + Stage 3: Reservation (skyscrapers, terminals, MultiBlockReservation) + Stage 4: Social site + NPC (triangles, society profile, DerivedDistrictAnalysis) + Stage 5: Guarantee audit (3-tier conditional check) + ↓ +Phase 2: ChunkData (on-demand per player approach) + Heritage grammar applied at chunk fill time + ↓ +World State Layer: DamageOverlay + DeltaLayer overlay at render time +``` + +### Layout Mode + +```rust +enum DistrictLayoutMode { + Grid, + Organic { + placements: [[BlockPlacement; 4]; 4], + }, +} +struct BlockPlacement { + offset: (i16, i16), // ±16 sim tiles per axis + rotation_steps: u8, // 0–3 (15° increments; hard cap at 45°) + street_width_factor: f32, // 0.75–2.0 relative to standard +} +``` + +Hard technical constraint: maximum rotation is ±45°. This is non-negotiable — beyond 45°, tile-based pathfinding produces unacceptable movement artifacts. Organic districts produce the visual impression of curved streets through angular jogs and irregular setbacks, not smooth curves. + +### WorldTier and ComplexityTier + +```rust +enum WorldTier { + Epicenter, // Hub system. Full simulation, high faction pressure. + Regional, // Regional. 1–4 districts, partial full-budget. + Backwater, // Small community. 1 district. Network-insignificant, NOT budget-capped. + Passage, // Transit stop. Pass-through. + Waypoint, // Not simulated until player approaches. +} +enum ComplexityTier { + Full, // All spatial guarantees. Rich NPC population. + Moderate, // Tier 1 + partial Tier 2 guarantees. Moderate NPCs. + Minimal, // Tier 1 only. Sparse NPCs. + Empty, // No social sites, no NPCs. Pure terrain. +} +``` + +WorldTier → ComplexityTier ceiling: + +| WorldTier | ComplexityTier ceiling | +|-----------|----------------------| +| Epicenter | Full | +| Regional | Full | +| Backwater | Full (key insight: dense isolated community — network insignificance ≠ simulation budget cap) | +| Passage | Moderate | +| Waypoint | Minimal | + +ComplexityTier → DramaDensity ceiling: Full → any intensity; Moderate → Active max; Minimal → Quiescent max; Empty → Zero only (no storyteller activation possible). A `ComplexityTier::Empty` district has no social fabric; the storyteller cannot activate drama there. + +--- + +## The 14 D-Ready Items + +These 14 items are confirmed D-records ready to be filed in `decisions/`. Each has been reviewed and signed off by all workshop participants. + +### D-READY-1: DistrictLayoutMode — Grid and Organic Support + +Both layout modes coexist. Grid = power imposed (Commission-planned). Organic = power negotiated (pioneer settlements, organic growth). Organic mode uses `BlockPlacement` offsets and rotations to produce non-rectilinear street space as negative space between shifted/rotated blocks. 45° rotation is a hard technical ceiling. + +The proportion of Grid vs. Organic districts across a world must vary per seed to prevent predictable meta-level patterns. + +### D-READY-2: Guarantee Tier System — Universal / Full-Only / Conditional + +**Tier 1 — Universal (all inhabited):** Social Hub, Informal Zone, Encounter Corridor. +**Tier 2 — Full-complexity:** Traffic Chokepoint, Institutional Space, Insider Space, Economic Node, Horizon View Corridor (coastal), BreachOnly Zone (≥1), Rooftop Discovery Zone (tall structures). +**Tier 3 — Conditional:** A-1 Elevated Vantage, A-2 Egress Multiplicity, A-3 Temporal Opacity Window, A-4 Non-Institutional Route, Economic Asymmetry Signal, Power Gradient Visibility. + +Audit runs all applicable checks. A Minimal farmstead gets ~3 checks. A Full-complexity coastal urban hub gets up to 13. + +Archetype placement must vary in **angular position** (not just distance from center) across seeds. The guarantee audit should fail if archetype positions cluster predictably across a test batch of N seeds. + +### D-READY-3: TrianglePurpose Enum + +```rust +enum TrianglePurpose { + Investigation, Economic, Social, Political, + Tactical, // target + protector + informant/witness + Mundane, +} +``` + +Triangles carry `Vec<TrianglePurpose>`. Purpose tags are multi-playstyle accessibility features: they ensure the right drama is surfaced to the player whose lens is active. `Tactical` encodes the assassination contract in spatial form. + +### D-READY-4: WallBackside / TileBehindState — Dual Classification + +Both enums are canonical. They serve complementary roles: +- `WallBackside` (Tyre): structural — what is physically behind this wall tile (AdjacentSpace / StructuralFill / ServiceVoid / ChunkBoundary / Exterior) +- `TileBehindState` (Gestalt): gameplay — what kind of space this represents (StructuralFill / HiddenRoom / Interstitial) + +Mapping: `WallBackside::ServiceVoid` → `TileBehindState::Interstitial`. `WallBackside::AdjacentSpace` → `TileBehindState::HiddenRoom` or `StructuralFill` depending on access tier. + +Era-tagged infrastructure cavity contents with standardized color codes: +- Era 1: power conduit only (`#c8b840`) +- Era 2: power + water/coolant (`#4888c8`) + comm lines (`#b8b8b8`) +- Era 3: full bundle (all types, denser) + +Backside assignments within a template must have seed-driven variation — not fixed-template values. + +### D-READY-5: Dynamic Modification via Overlay (Not Re-Generation) + +Generator output is immutable. All post-generation modifications are applied via overlay. + +**`DamageOverlay`:** +```rust +struct DamageOverlay { + overlay_type: DamageOverlayType, + epicenter: ChunkLocalPos, + radius: f32, + intensity: f32, + scatter_seed: u64, // variation within damage zone only +} +enum DamageOverlayType { GasExplosion, Fire, Structural { collapse_direction }, Flooding } +``` + +**`RegenerationStrategy`:** +```rust +enum RegenerationStrategy { + LocalOverlay(DamageParameters), // in-playthrough — MANDATORY + SoftReseed { seed_modifier: u64 }, // scenario-boundary only + FullReseed, // era-level discontinuity only +} +``` + +Hard constraint: in-playthrough events are ALWAYS `LocalOverlay`. XOR reseeding for in-playthrough events is explicitly prohibited. + +Trauma event → visual stage mapping: +- PhysicalDestruction/ViolenceEvent → Stage 2 (Fresh Aftermath), decays to Stage 3 +- EconomicDisruption/PoliticalShock/MigrationShock → quarter fill modifier (not destruction stages) + +Full destruction stage sequence: + +| Stage | Name | Visual state | +|-------|------|-------------| +| 1 | Active | Event in progress; DamageOverlay rendering live | +| 2 | Fresh Aftermath | Structure breached; scorch, rubble, debris tiles visible | +| 3 | Stabilized | Debris cleared; structural state permanent | +| 4 | Reconstruction | Scaffolding tiles, incomplete floor sections | +| 5 | Healed Scar | Functional again; residual visual tells remain | + +Destruction palette constraint: corruption-only. No new colors are introduced by destruction. Existing zone palette tiles are darkened, desaturated, or replaced with structural-damage variants from the same palette family. Single exception: `#c8d8f0` (open-sky tile) appears at 100% intensity when a roofed structure has its roof removed — the only color destruction may introduce. Implementers must not create a separate destruction color set. + +Replayability: the modification history diverges per playthrough based on event decisions. Same-seed worlds share the same generator baseline; different event histories produce different delta layers. This is the replayability engine. + +### D-READY-6: ZonePalette Modifier System + +```rust +struct ZonePalette { + base: BasePalette, + modifiers: Vec<PaletteModifier>, +} +``` + +8 base terrain types (T1 temperate farmland [warm organic, natural lighting] / T2 industrial farmland [cool grey-green, artificial lighting] / T3 wilderness / T4 grassland / T5 coastal water [deep near-black blue, animated specular; referenced by D-READY-7 horizon corridor guarantee] / T6 beach/coastal margin [warm dark tan] / T7 mountain/high terrain [dark blue-grey stone, snow at elevation] / T8 desert/arid). T1 and T2 are the two farmland types, explicitly distinct. If wetland terrain is required, it must be specified as a new T9 type — it is not a replacement for any of the 8 canonical types. + +Modifier axes: A (heritage root → material character), B (economic tier → condition/density), C (era → material generation), plus faction overlay, climate, condition, season. + +Palette modifiers should influence NPC appearance as well as environment appearance. People dress like they're from here. + +### D-READY-7: Horizon View Corridor as Coastal Guarantee + +A **negative-space** reservation: ≥8 visual tiles unobstructed view corridor from nearest public street to water's edge. No building, tree, or z=4 element may occupy this corridor. A low z=2 element (railing, bench, bollard) marks the waterfront point as a designed viewing location. + +Tier 2 Conditional guarantee for coastal districts. Position within the district must vary per seed — the Wow Moment of seeing the horizon must be discovered, not expected. + +### D-READY-8: Assassin Lens Spatial Guarantees (A-1 through A-4) + +These are **derived properties of existing spatial configuration**, not assassin-tagged features. They add no generation cost; the audit validates existing output. + +- **A-1 Elevated Vantage** (Tier 3, Full-complexity): ≥1 position with clear LOS cone to Traffic Chokepoint. Generator ensures overhead-clear zone in LOS corridor during block planning. +- **A-2 Egress Multiplicity** (Tier 3, Full-complexity): ≥2 exit routes to adjacent districts. +- **A-3 Temporal Opacity Window** (Tier 3, Full-complexity): ≥1 time window (day-phase) where Social Hub has reduced ambient NPC coverage. +- **A-4 Non-Institutional Access Route** (mandatory Full-complexity): ≥1 route to any Insider zone that does not pass through high-security institutional spaces. + +A-1/A-2/A-3 are Tier 3 Conditional (trigger on `complexity_tier == Full`). A-4 is mandatory Full-complexity for all playstyles. + +### D-READY-9: Heritage Grammar Overlay for Non-Urban Palettes + +Data-driven `HeritageGrammarOverlay` structs (10 per heritage root). Loaded once at generator startup. Applied at Phase 2 chunk fill time by weighted blending. + +Blend rules: +- Continuous fields (decorative_density, repair_visibility, etc.): weighted average +- Categorical fields (boundary_character, open_space_character): dominant heritage weight wins +- Object tag lists: union of preferred/accent tags; intersection-exclusion of excluded tags + +Phase 1 exception: `gathering_probability` evaluated at block planning for quarter pre-assignment. + +Authoring domain separation: +- **Miri:** organizational principles, boundary character, spacing, social grammar (HeritageGrammarOverlay Rust struct / authored data) +- **Araminta:** visual expression — object sets, arrangement algorithms, floor surface variants, overhead flora density and character, wall/structure material character, boundary material type, lighting temperature (TOML modifier files, one per heritage root) + +Shared requirement: `ObjectTag` vocabulary must be co-maintained. + +### D-READY-10: Non-Urban Informal Zone Typology + +Informal zones are defined as spaces outside the community's social field — not defined by institutional absence but by the type of social permission governing them. + +Three types: +- `social_permission`: normal zone palette; gathering infrastructure present; cover is about convention, not geography +- `physical_distance`: sparse objects, unmaintained floor; isolation is the visual +- `utilitarian_cover`: functional work objects; space reads as work space; unofficial use is invisible to casual observation + +Visual grammar per type in `docs/workshops/generator-architecture/araminta-round4.md`. + +Heritage root correlation: Frost/Stone → `physical_distance`; Tide/Vine/Dust → `social_permission`; Iron/Salt → `utilitarian_cover`. Location within terrain is seeded independently. (Dust = maximum communal observation, only privacy available is negotiated; Iron = labor function covers presence. Both confirmed Miri Round 5.) + +### D-READY-11: Vertical Scale Architecture + +Four height tiers (S1–S4): +- S1: 1–2 z-levels (surface + roof/mezzanine) +- S2: 3–10 z-levels +- S3: 11–30 z-levels +- S4: 30+ z-levels + +Shadow length is the primary height signal in top-down view (2–40 visual tiles). + +Lazy z-level loading: `ZLevelLoadState: Loaded | Skeleton | Ungenerated`. Only current + adjacent z-levels filled by Phase 2. + +**Rooftop Bar Clause:** Every tall structure (z_band_count ≥ 3) must assign a `RooftopConfig: Restricted | PublicWithHiddenLayer`. The discovery layer is mandatory in both cases. Heritage root **weights the probability** between the two configs — it does not determine the outcome. A minority of buildings of any heritage root must be configurable as the non-dominant type. A Frost building with a rooftop bar must be possible; full determination kills the discovery moment. (Correction confirmed by Ozzie + Araminta, Round 5.) + +Z-band floor boundaries must have seed-variation within cultural ordering constraints. A corporate building has executive floors in the upper zone, but which exact floor begins is seeded. + +Vertical access routes are playthrough-history dependent: same building, different routes available based on player relationship and event history. + +### D-READY-12: Trauma Events as EraModification Subtypes + +```rust +enum ModificationType { + TraumaEvent { + subtype: TraumaEventSubtype, + cultural_aftermath: HeritageRootResponse, + } +} +enum TraumaEventSubtype { + PhysicalDestruction, EconomicDisruption, PoliticalShock, + ViolenceEvent, MigrationShock, +} +``` + +Trauma events that physically alter structures apply damage via `LocalOverlay`. The original_seed is preserved. Cultural aftermath decays toward baseline at heritage-root-dependent rates. + +Physical destruction and cultural aftermath are separate tracks: +- Structural damage: `StructuralChange` in ChunkMutations +- Cultural response: NPC weight distribution shift in `DistrictRuntimeState.npc_pattern_weights` + +Decay rate is seeded per-community with variation around heritage-root baseline (prevents perfect predictability from heritage root alone). + +`trauma_visual_decay_rate: slow | medium | fast` per heritage root. Default: medium. + +Design principle: **Trauma intensifies culture, it does not transform it.** A stressed community becomes a more concentrated version of itself — Frost communities close harder, Tide communities grief more publicly, Iron communities organize more collectively. Decay is toward the community's pre-trauma baseline, not toward a new equilibrium. Players who have learned a heritage root's trust model can predict community behavior in the aftermath. + +### D-READY-13: MobileChunk Specification + +Entity-carried interior space attached to a mobile world entity. Not a district. Uses the same chunk fill primitives in a simpler flat structure (no Phase 1/Phase 2 split; no block grid; no zone negotiation). + +Key structs: `MobileChunk`, `MobileInterior`, `VesselClass`, `MobileMovementState` (Docked / InTransit / InterSystem / Idle), `TransitSocialModifier`, `MobileNpcSlot`, `NpcPersistence` (Crew / Passenger). Note: `Idle` = vessel parked at a location but not docked to infrastructure (anchored ship, grounded shuttle). + +Vessels are **persistent world entities**. In `Docked` state: present at dock_position, visible from dock as sprite overlay, boarding via gangway tile → MobileAccessPoint activation. Interior cache keyed by entity_id persists across voyages for crew state. + +**Departure schedules** are required as a generator output. The `Docked` struct must include `docked_since: SimTick` and `scheduled_departure: Option<SimTick>` — these fields were absent from Tyre's Round 4 canonical struct and must be added at implementation time. The generator must populate `scheduled_departure`. Vessels without departure schedules are an error state. + +Replayability requirements R-V-1 through R-V-6 (see round-4-notes.md §5, D-READY-13 section). + +Memory: ~0.5–4 KB metadata + up to 64 KB ChunkData per vessel. At 50 active entities: ~3 MB, paged by streaming model. + +**Cultural grammar:** `TransitSocialModifier` with `TransitVariant` (BoundedLinear / BoundedMobile / InterSystem). Heritage-root behavior tables by vehicle type. Miri's canonical spec at `docs/workshops/generator-architecture/miri-round4.md`. + +**Vessel visual grammar:** see `docs/workshops/generator-architecture/araminta-round4.md` §2. Five rules govern visual distinction of MobileChunk interiors from static zone spaces: (1) exterior hull uses vessel-identity material, not zone palette; (2) window tiles reveal exterior context (docked vs. in transit); (3) compression modifier tightens proportions throughout; (4) section transitions use vessel-identity threshold elements; (5) class stratification expressed through proportion, not palette change. + +### D-READY-14: DamageOverlay / RegenerationStrategy + +See D-READY-5 for full specification. Filed separately as a D-record because it establishes the general modification strategy rather than only the overlay mechanics. + +The key distinction: this D-record establishes the **prohibition** of XOR reseeding for in-playthrough events and the **mandate** for `LocalOverlay`. All participants confirmed this unanimously in Round 4. + +--- + +## NPC Model — The Ysabel Vorn Litmus Test + +The 10-axis NPC model was validated against a concrete NPC exercise (Miri, Round 4). Ysabel Vorn covers **4.5 of 5 playstyle hooks** on a Backwater/Moderate farming settlement. + +**The 10 axes:** +1. Behavioral Pattern (social archetype: ANCHOR, REMNANT, WITNESS, etc.) +2. Surface Motivation (publicly visible goal) +3. Actual Motivation (what they actually want) +4. Vulnerability/Secret +5. Information Access (tiered knowledge inventory) +6. Trust Architecture (heritage-based trust model + specific trust network) +7. Routine Pattern (daily/weekly/seasonal schedule) +8. Economic Position (control levers + hidden assets) +9. Relationship Network (triangle memberships — active and latent) +10. Tolerance Threshold (per-trigger tolerance levels) + +**The gap (Axis 11, proposed):** `network_footprint: Option<NetworkFootprintTag>` for NPCs who are locally insignificant in appearance but carry network-significant information or are relevant to external actors. Default `None` for procedural NPCs. Set explicitly for authored scenario NPCs. + +This axis is not yet in the confirmed model — it is raised as a Q-record for sprint work. + +**Minimum NPC count for intra-seed replayability:** 3 (one functional triangle). One NPC = maximum seed-to-seed variation, zero intra-seed emergence. Three NPCs = triangles, shifting alliances, cascade effects. Even Minimal-complexity insignificant districts need 3 NPCs. + +--- + +## Key Tensions and Resolutions + +| Tension | Round | Resolution | +|---------|-------|-----------| +| Grid-only vs. organic streets | R1–R3 | Both. Grid = power imposed; Organic = power negotiated. Both modes coexist in the same world. | +| SignificanceTier vs. WorldTier naming | R3–R4 | Lead: WorldTier. Canonical values: Epicenter/Regional/Backwater/Passage/Waypoint. | +| MobileChunk (entity-carried) vs. instanced district (Nigel) | R3–R4 | Lead: entity-carried MobileChunk. Vessels are persistent world entities. | +| DramaDensity on struct vs. runtime | R3–R4 | Lead: runtime only. DramaDensity lives in DistrictRuntimeState, not DistrictSkeleton. | +| XOR reseeding vs. structured damage | R3–R4 | Unanimous: DamageOverlay. XOR prohibited for in-playthrough events. | +| Assassination difficulty: computed-on-demand (Gestalt) vs. Phase 1 stored (Miri) | R4 | Minor tension. Recommended synthesis: stored cultural baseline (DerivedDistrictAnalysis on skeleton) + on-demand computation for player-facing assessment. See round-4-notes §2, OQ-R4-C. | + +--- + +## Open Questions for Sprint Work + +| Q-ID | Question | Priority | Owner | +|------|----------|----------|-------| +| Q-NNN-a | Axis 11 (Network Footprint) — authored field for network-significant NPCs in locally-insignificant positions | High | Miri | +| Q-NNN-b | Departure schedule model — departure windows as generator output for docked vessels (Ozzie requirement). **Note R5:** D-READY-13 resolves this — `scheduled_departure: Option<SimTick>` in Docked state is mandatory generator output. Recommend closing before sprint planning. | High | Tyre + Miri | +| Q-NNN-c | Mobile environment social arc — structural representation of journey timeline (Ozzie requirement) | Medium | Miri + Gestalt | +| Q-NNN-d | DramaDensity enum naming — Round 4 struct uses Quiescent/Active/Intense (3) vs. Round 3's Zero/Low/Medium/High/Flashpoint (5). Resolve before D-record. | Low | Tyre + Gestalt | +| Q-NNN-e | ObjectTag vocabulary co-maintenance — shared between Miri's HeritageGrammarOverlay and Araminta's asset categorization | Medium | Miri + Araminta | +| Q-NNN-f | Assassination difficulty synthesis — formal spec combining DerivedDistrictAnalysis baseline (Phase 1) with on-demand runtime computation (player-facing display only; game logic uses Phase 1 value) | Medium | Gestalt + Miri | + +--- + +## Implementation Targets (From Participant Estimates) + +| Feature | Target version | Estimated effort | +|---------|---------------|-----------------| +| Phase 1 DistrictSkeleton (basic) | v0.3 | ~3 dev-days | +| Phase 2 chunk fill with heritage grammar | v0.3 | ~4 dev-days | +| MobileChunk (single-chunk vessels) | v0.3 | ~9.5 dev-days | +| Vertical scale (z-bands, lazy loading) | v0.4 | ~7 dev-days | +| DamageOverlay system | v0.4 | ~1.5 dev-days | +| MobileChunk::Block (large ships) | v0.5 | Deferred | + +--- + +## What This Generator Promises the Player + +From Ozzie's synthesis across all four rounds: + +> **The world is real and persistent.** Vessels exist when you're not on them. The crew you met last voyage is still there. The damage you caused is still there. +> +> **Every wall is a secret keeper.** WallBackside + BreachOnly means no tile is ever void. There's always something behind the wall. +> +> **Destruction has history.** DamageOverlay + trauma subtypes mean the aftermath of events is legible. You can arrive at a district and read what happened. +> +> **Height has meaning.** Vertical scale + view down from above. The building is itself a puzzle. Floor 30 has information floor 1 can't have, because floor 30 is harder to reach. +> +> **Every playstyle has guaranteed affordances.** The 3-tier guarantee system and the assassin lens guarantees mean the generator is making contracts it keeps. +> +> **The journey is content.** Mobile environments are social pressure cookers, not loading screens with chairs. +> +> **Insignificance is a lens, not a verdict.** A Minimal/Dormant district contains a complete small society. The playstyle is the starting assumption the world eventually corrects. + +--- + +*Workshop closes. Fourteen D-records ready for filing. Six Q-records raised for sprint work. The generator pipeline is locked.* diff --git a/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md b/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md index 5b0010e75..8aab59e31 100644 --- a/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md +++ b/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md @@ -833,9 +833,9 @@ if graph.entity_knowledge.len() > MAX_ENTITY_KNOWLEDGE { --- **Files referenced:** -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/npc/mod.rs` (lines 47-49: InformationInventory) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/bridge/types.rs` (line 26: entity_id) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/simulation/tier.rs` (tier system) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md` (D-010, D-020, D-026, D-030) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md` (D-011, D-017) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-019) +- `/var/mnt/data/projects/settled-reach/planning/server/src/npc/mod.rs` (lines 47-49: InformationInventory) +- `/var/mnt/data/projects/settled-reach/planning/server/src/bridge/types.rs` (line 26: entity_id) +- `/var/mnt/data/projects/settled-reach/planning/server/src/simulation/tier.rs` (tier system) +- `/var/mnt/data/projects/settled-reach/planning/decisions/architecture.md` (D-010, D-020, D-026, D-030) +- `/var/mnt/data/projects/settled-reach/planning/decisions/perception.md` (D-011, D-017) +- `/var/mnt/data/projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-019) diff --git a/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md b/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md index bdebc14f7..b085b84e8 100644 --- a/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md +++ b/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md @@ -727,8 +727,8 @@ Let's build this. --- **Files referenced:** -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md` (D-011, D-015-D-019) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md` (D-010, D-026) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/content.md` (D-024, D-028, D-033) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-017) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/npc/mod.rs` (current NPC model) +- `/var/mnt/data/projects/settled-reach/planning/decisions/perception.md` (D-011, D-015-D-019) +- `/var/mnt/data/projects/settled-reach/planning/decisions/architecture.md` (D-010, D-026) +- `/var/mnt/data/projects/settled-reach/planning/decisions/content.md` (D-024, D-028, D-033) +- `/var/mnt/data/projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-017) +- `/var/mnt/data/projects/settled-reach/planning/server/src/npc/mod.rs` (current NPC model) diff --git a/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md b/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md index c8f1666e8..838fd2362 100644 --- a/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md +++ b/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md @@ -879,18 +879,18 @@ KnowledgeGraph::last_change(&self) -> Option<(StableId, KnowledgeSource, u64)> ## Files Referenced -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/npc/mod.rs` -- Current NPC component model, InformationInventory placeholder (line 47-49) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/perception/mod.rs` -- PerceptionPlugin stub -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/bridge/types.rs` -- ObserverSnapshot, VisibleEntity, wire protocol types -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/simulation/movement.rs` -- TilePosition, WalkabilityMap, validate_movement -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/simulation/tier.rs` -- SimulationTier, ScopeTag, LastInteraction -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/src/cause_chain.rs` -- CauseChain, CauseKind (aligns with KnowledgeSource) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/server/Cargo.toml` -- Dependency inventory -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md` -- D-010, D-020, D-026, D-030, D-031 -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md` -- D-011, D-015, D-017, D-018, D-033 -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/content.md` -- D-028, D-034, D-035 -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md` -- Q-016, Q-017, Q-018, Q-019 -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/audits/architecture-review-2026-02-11.md` -- Architecture review consensus -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/server.md` -- Sprint 2 server tasks -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/joint.md` -- Sprint 2 joint tasks, integration proof -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/workshop-brief.md` -- Workshop brief +- `/var/mnt/data/projects/settled-reach/planning/server/src/npc/mod.rs` -- Current NPC component model, InformationInventory placeholder (line 47-49) +- `/var/mnt/data/projects/settled-reach/planning/server/src/perception/mod.rs` -- PerceptionPlugin stub +- `/var/mnt/data/projects/settled-reach/planning/server/src/bridge/types.rs` -- ObserverSnapshot, VisibleEntity, wire protocol types +- `/var/mnt/data/projects/settled-reach/planning/server/src/simulation/movement.rs` -- TilePosition, WalkabilityMap, validate_movement +- `/var/mnt/data/projects/settled-reach/planning/server/src/simulation/tier.rs` -- SimulationTier, ScopeTag, LastInteraction +- `/var/mnt/data/projects/settled-reach/planning/server/src/cause_chain.rs` -- CauseChain, CauseKind (aligns with KnowledgeSource) +- `/var/mnt/data/projects/settled-reach/planning/server/Cargo.toml` -- Dependency inventory +- `/var/mnt/data/projects/settled-reach/planning/decisions/architecture.md` -- D-010, D-020, D-026, D-030, D-031 +- `/var/mnt/data/projects/settled-reach/planning/decisions/perception.md` -- D-011, D-015, D-017, D-018, D-033 +- `/var/mnt/data/projects/settled-reach/planning/decisions/content.md` -- D-028, D-034, D-035 +- `/var/mnt/data/projects/settled-reach/planning/decisions/questions.md` -- Q-016, Q-017, Q-018, Q-019 +- `/var/mnt/data/projects/settled-reach/planning/docs/audits/architecture-review-2026-02-11.md` -- Architecture review consensus +- `/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/server.md` -- Sprint 2 server tasks +- `/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/joint.md` -- Sprint 2 joint tasks, integration proof +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/workshop-brief.md` -- Workshop brief diff --git a/docs/workshops/knowledge-graph-information-boundaries/round2-gestalt.md b/docs/workshops/knowledge-graph-information-boundaries/round2-gestalt.md index 6e98377c3..3470ca58e 100644 --- a/docs/workshops/knowledge-graph-information-boundaries/round2-gestalt.md +++ b/docs/workshops/knowledge-graph-information-boundaries/round2-gestalt.md @@ -578,12 +578,12 @@ Let's build this. ## Files Referenced -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-paula.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-si.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md` (D-011, D-015-D-019, D-033) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/content.md` (D-024, D-028, D-034, D-035) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md` (D-010, D-020, D-026, D-030) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-017) +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-paula.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-si.md` +- `/var/mnt/data/projects/settled-reach/planning/decisions/perception.md` (D-011, D-015-D-019, D-033) +- `/var/mnt/data/projects/settled-reach/planning/decisions/content.md` (D-024, D-028, D-034, D-035) +- `/var/mnt/data/projects/settled-reach/planning/decisions/architecture.md` (D-010, D-020, D-026, D-030) +- `/var/mnt/data/projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-017) diff --git a/docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md b/docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md index 9c0b97beb..721e1ec34 100644 --- a/docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md +++ b/docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md @@ -1179,13 +1179,13 @@ That is actually elegant. --- **Files referenced:** -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/workshop-brief.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-si.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-paula.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/architecture.md` (D-010, D-020, D-026, D-030, D-031) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/perception.md` (D-011, D-015, D-017, D-018, D-033) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/content.md` (D-024, D-028, D-034, D-035) -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-019) +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/workshop-brief.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-dudley.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-gestalt.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-si.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-tyre.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round1-paula.md` +- `/var/mnt/data/projects/settled-reach/planning/decisions/architecture.md` (D-010, D-020, D-026, D-030, D-031) +- `/var/mnt/data/projects/settled-reach/planning/decisions/perception.md` (D-011, D-015, D-017, D-018, D-033) +- `/var/mnt/data/projects/settled-reach/planning/decisions/content.md` (D-024, D-028, D-034, D-035) +- `/var/mnt/data/projects/settled-reach/planning/decisions/questions.md` (Q-016, Q-019) diff --git a/docs/workshops/knowledge-graph-information-boundaries/sprint2-impact.md b/docs/workshops/knowledge-graph-information-boundaries/sprint2-impact.md index e9b7f861b..ca51544a0 100644 --- a/docs/workshops/knowledge-graph-information-boundaries/sprint2-impact.md +++ b/docs/workshops/knowledge-graph-information-boundaries/sprint2-impact.md @@ -331,8 +331,8 @@ INSERT INTO ticket_deps (blocker_id, blocked_id) VALUES (361, 182); -- Knowledg **End of Sprint 2 Impact Summary** **Files referenced:** -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/server.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/joint.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/docs/sprints/sprint-2/client.md` -- `/var/home/jeroenschweitzer/Projects/settled-reach/planning/db/connectors/ticket` (CLI used for ticket operations) +- `/var/mnt/data/projects/settled-reach/planning/docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/server.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/joint.md` +- `/var/mnt/data/projects/settled-reach/planning/docs/sprints/sprint-2/client.md` +- `/var/mnt/data/projects/settled-reach/planning/db/connectors/ticket` (CLI used for ticket operations) diff --git a/docs/workshops/knowledge-graph-information-boundaries/workshop-outcomes.md b/docs/workshops/knowledge-graph-information-boundaries/workshop-outcomes.md new file mode 100644 index 000000000..f59af5bac --- /dev/null +++ b/docs/workshops/knowledge-graph-information-boundaries/workshop-outcomes.md @@ -0,0 +1,90 @@ +# Workshop Outcomes: Knowledge Graph & Information Boundaries + +**Workshop:** Knowledge Graph & Information Boundaries +**Date:** 2026-02-11 +**Rounds:** 2 (Design + Synthesis) +**Participants:** Tyre, Gestalt, Paula, Dudley, Si +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** DONE — fully actioned, decisions filed, tickets created +**Full notes:** `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md` + +--- + +## What the Workshop Accomplished + +Replaced the `InformationInventory { known_facts: Vec<String> }` placeholder with a fully specified knowledge graph data model. Five agents analyzing from different angles converged independently on all fundamentals: per-entity ECS component, stable entity IDs, per-entry provenance tracking. The synthesis resolved the only substantive debate (centralized resource vs. per-entity component) unanimously in favour of per-entity. The resulting D-041 spec is the foundation for asymmetric information as a playable mechanic — and became load-bearing for D-010, D-011, D-017, D-028, D-033, and Q-016. + +--- + +## Major Decision Produced + +### D-041: Knowledge Graph Data Model + +The core design decision of this workshop. Full specification in `decisions/perception.md`. + +Key architectural choices: +- `KnowledgeGraph` as a Bevy ECS `Component` on each entity (not a centralized resource) +- `StableEntityId` (`u64`-based) for cross-reference stability; runtime `EntityRegistry` for bidirectional mapping +- `BTreeMap<StableId, EntityKnowledge>` + `BTreeMap<FactId, FactKnowledge>` per entity +- Four confidence levels: `Direct` > `KnowsDetails` > `KnowsOf` > `Suspects` +- Three knowledge states: `Active`, `Stale`, `Contradicted` +- `KnowledgeSource` tracked per-entry (not per-graph): `DirectObservation`, `ToldBy`, `Background`, `Heard` +- Event-driven updates via `KnowledgeEventQueue`; decay pass once per game-minute +- Sprint 2 scope: data structures + direct observation + basic decay only + +### Additional Decisions Implied (formalized later) + +The workshop produced the architectural foundation that fed into: +- D-012: Information boundaries (per-character knowledge isolation) +- Formal resolution of Q-016: Knowledge hierarchy (`Suspects` < `KnowsOf` < `KnowsDetails` < `Direct`) + +--- + +## Open Questions Identified + +| ID | Question | Sprint Impact | Resolved By | +|----|----------|---------------|-------------| +| Q-024 | Gossip propagation timing (immediate vs queued) | Sprint 3+ | D-080 (knowledge-flow-npc-boundaries workshop) | +| Q-025 | Knowledge graph cap and eviction policy | Sprint 3+ | D-080 (closed: no cap needed at projected v0.1 scale) | +| Q-026 | Contradiction detection algorithm | Sprint 3+ (THE FRIEND arc) | D-083 (knowledge-flow-npc-boundaries workshop) | + +None blocked Sprint 2. + +--- + +## Tickets Created + +8 new tickets added to Sprint 2, all under epic #351. Sprint 2 expanded from 14 to 22 tickets (+6.5 developer-days). + +| # | Title | Priority | Estimate | +|---|-------|----------|----------| +| #361 | KnowledgeGraph component + types (D-041) | critical | 1 day | +| #362 | StableEntityId + EntityRegistry resource | critical | 1 day | +| #363 | KnowledgeEventQueue + processing system | high | 0.5 day | +| #364 | Direct observation knowledge flow | critical | 0.5 day | +| #365 | Basic knowledge decay system | high | 0.5 day | +| #366 | Observer snapshot knowledge integration | critical | 1 day | +| #367 | Knowledge graph unit test suite | high | 1 day | +| #368 | Knowledge vocabulary for v0.1 content | high | 0.5 day | + +**Existing tickets affected:** +- #89 (Information inventory) — cancelled, subsumed by #361 +- #269 (CauseChain component) — marked done (already implemented) +- #138-142 (Information boundary epics) — reparented under #351; #139, #141, #142 deferred to Sprint 3; #140 cancelled (merged into #366) + +**Critical path impact:** #361, #362, #363, #364 added serially to Sprint 2 critical path (10 tickets serial, up from 6). + +--- + +## Sprint 2 Completion Criteria (Added by Workshop) + +Two new acceptance criteria added to Sprint 2's definition of done: +- Entity color reflects relationship state from knowledge graph (#361, #366) +- Remembered (not visible) entities appear as ghosts at last-known position (#361, #366) + +**Knowledge graph proof:** Observe an NPC, walk away, return. NPC appears as ghost at last-known position while not in LOS. Color shifts by relationship state. + +--- + +*Compiled by Qatux. Source: `docs/workshops/knowledge-graph-information-boundaries/round2-synthesis.md`, `sprint2-impact.md`. Primary decision in `decisions/perception.md` (D-041).* diff --git a/docs/workshops/v01-content-scoping/workshop-outcomes.md b/docs/workshops/v01-content-scoping/workshop-outcomes.md new file mode 100644 index 000000000..196460f28 --- /dev/null +++ b/docs/workshops/v01-content-scoping/workshop-outcomes.md @@ -0,0 +1,116 @@ +# Workshop Outcomes: v0.1 Content Scoping + +**Workshop:** v0.1 Content Scoping +**Date:** 2026-02-12 +**Rounds:** 2 + closing round (lead resolutions) +**Participants:** Gestalt, Paula, Tyre, Mellanie, Stig, Dudley, Si, Qatux +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** CLOSED — all recoverable decisions filed, tickets created +**Full notes:** `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md` + +--- + +## What the Workshop Accomplished + +Applied the Wiki Review's long-term generator strategy to the immediate v0.1 hand-authored proof. Produced 20 decisions (D-042 through D-061), 38 new tickets, canonical NPC mapping for 17 characters, the 16-key EntityKnowledge specification, full content directory architecture, and a Sprint 3-5 roadmap. Tyre and Dudley independently produced structurally identical ObserverSnapshot v3 definitions without coordination — confirmed the architecture was sound. Lead issued 4 decisions resolving the major Round 1 disagreements between rounds, then resolved 3 remaining questions in a closing round. + +**Note on decision IDs:** Several IDs assigned at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-087 (content directory structure), D-089 (NPC canonical mapping method), D-091 (EntityKnowledge 16-key canonical set), Q-031 through Q-034. + +--- + +## Decisions Produced + +### From Round 1 Consensus (7) + +| ID | Decision | Domain | +|----|----------|--------| +| D-042 | Drin promoted from Tier 3 to Tier 2 | content.md | +| D-043 | THE NOBODY mechanic deferred to v0.2; hidden data ships in v0.1 content | scope.md | +| D-044 | v0.1 interaction model: 7 verbs (Move, Look, Monologue, Examine Object, Examine NPC, Talk, Overhear) | scope.md | +| D-045 | v0.1 scope IN: news ticker, PC-as-NPC, time progression, relationship state transitions | scope.md | +| D-046 | v0.1 scope OUT: inventory, stealth, combat, save/load, lattice modification | scope.md | +| D-047 | v0.1 triangles: 3 active forks (T1, T2, T4), 2 passive tensions (T3, T5) | content.md | +| D-048 | Client receives all text from server via state updates; client does not load content files | architecture.md | + +### From Round 2 + Closing (13) + +| ID | Decision | Domain | +|----|----------|--------| +| D-049 | YAML is the content file format for v0.1; RON is optional build-time optimization | architecture.md | +| D-050 | Gestalt's NPC pattern/motivation mapping canonical for v0.1; Paula's emotional layer becomes v0.2 annotations | content.md | +| D-051 | v0.1 ships single context-sensitive verb; multi-verb architecture modeled underneath | architecture.md | +| D-052 | 3-state pause: Normal (100%), Overlay (50%), Paused (0%); server-authoritative | architecture.md | +| D-053 | Self-contained triangle forks for v0.1; no cross-triangle cascade (v0.2) | content.md | +| D-054 | ObserverSnapshot v3: adds sim_speed, nearby_interactions, active_dialogue, monologue, overheard, knowledge_updates, examine_result, ticker_headlines | architecture.md | +| D-055 | 16 EntityKnowledge keys; 4 new role-perspective keys; trust_read merged into trust_level; secret_held → leverage_held | architecture.md | +| D-056 | PC voice registers: smuggler (feeling-first, fragments, physical); detective (analysis-first, complete sentences, institutional) | content.md | +| D-057 | Content directory: content/ with _schema/, global/, districts/ top-level split; JSON Schema validation at build time | process.md | +| D-058 | THE FRIEND content pack template: Kael Davan, 91 lines across 5 arc phases | content.md | +| D-059 | Monologue display: 160 char max, 2-line max, 4-6s display, 2s cooldown, queue depth 1, 9-level priority | architecture.md | +| D-060 | actions[] renamed to verbs[] across all surfaces | architecture.md | +| D-061 | No ticket merges across domain teams | process.md | + +### Retrospective Filings (ID collisions resolved) + +| ID | Decision | Domain | +|----|----------|--------| +| D-087 | Content directory structure (content/ split) | process.md | +| D-089 | Canonical NPC pattern/motivation mapping method | content.md | +| D-091 | EntityKnowledge 16-key canonical specification | architecture.md | + +--- + +## Open Questions Carried Forward + +| ID | Question | Status | +|----|----------|--------| +| Q-031 | NPC surnames for Drin, Sess, Tav awaiting Miri validation | Informational | +| Q-032 | Interaction struct naming: AvailableActions (Tyre) vs EntityInteractions (Dudley) | Resolved at implementation | +| Q-033 | 695 authored items: validated as scope input but not independently verified | Informational | +| Q-034 | Dialogue max-width: pixel value for 20% height / max-width constraint | Pending lead call | + +None blocked Sprint 3. + +--- + +## Tickets Created + +38 new tickets + 10 existing ticket updates. See `si-ticket-changes.md` for full list. + +**Teams:** copy (21), server (13), client (2), ci (1). + +**Critical path:** #261 (Dual Lens Authoring Guide) is the single biggest blocker — directly blocks 9 downstream tickets across the content pipeline. Five-day time-box recommended. + +| Series | Count | Domain | +|--------|-------|--------| +| A (Wiki content fixes) | 8 | copy | +| B (Style guides + specs) | 5 | copy | +| C (Content directory + schemas) | 10 | copy/server/ci | +| D (Design specs) | 2 | server/copy | +| NEW 1-7 (Workshop rounds) | 7 | copy | +| NEW 8-14 (Lead decisions, excl. killed NEW-12) | 6 | server/client | + +**Killed:** NEW-12 (client pause state machine — pause is server-authoritative, client sends IPC command only). + +**Sprint allocation:** Sprint 3 — foundations and specs. Sprint 4 — content conversion and authoring begins. Sprint 5+ — content at scale. + +--- + +## NPC Canonical Mapping (17 NPCs) + +The Gestalt-Paula synthesis produced the v0.1 canonical pattern/motivation mapping for all 17 Sova NPCs. This is the authoritative reference for content authoring. + +| Name | Tier | Pattern | Motivation | +|------|------|---------|-----------| +| Kael Davan | T1 | FRIEND | OPERATOR | +| Sera Venn | T1 | FRIEND | WITNESS | +| Naia Tamm | T1* | MIRROR | CIVILIAN | +| Voss, Lera, Torek, Devra, Maret, Resha, Drin, Renn, Pell, Harek | T2 | (varied) | (varied) | +| Sess, Olin, Sabel, Tav | T3 | (varied) | (varied) | + +Off-stage: Nils Davan — GHOST + HANDLER. + +--- + +*Compiled by Qatux. Source: `docs/workshops/v01-content-scoping/SUMMARY.md`, `si-ticket-changes.md`. Decisions in relevant `decisions/` domain files (D-042 through D-061, D-087, D-089, D-091). Open questions in `decisions/questions.md` (Q-031 through Q-034).* diff --git a/docs/workshops/v01-gap-analysis/workshop-outcomes.md b/docs/workshops/v01-gap-analysis/workshop-outcomes.md new file mode 100644 index 000000000..2f4bda737 --- /dev/null +++ b/docs/workshops/v01-gap-analysis/workshop-outcomes.md @@ -0,0 +1,97 @@ +# Workshop Outcomes: v0.1 Gap Analysis + +**Workshop:** v0.1 Gap Analysis +**Date:** 2026-02-11 +**Rounds:** 2 (Gap identification + Synthesis) +**Participants:** Gestalt, Tyre, Ozzie, Paula, Nigel, Gore, Hoshe +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** DONE — fully actioned, decisions confirmed, tickets created +**Full notes:** `docs/workshops/v01-gap-analysis/si-ticket-changes.md` + +--- + +## What the Workshop Accomplished + +Stress-tested the full v0.1 plan (232 tickets, 7 initiatives) against three tracks: strength of concept proof, fun and wow factor, and completeness. Found significant gaps: missing infrastructure (collision, pathfinding, time system), a missing experience layer (observation pipeline, interaction verbs), and systemically underpriced tickets across social, dialogue, and replayability systems. The workshop ended with 273 tickets (+41) and a clear Sprint 1-5 dependency chain. + +Key outcome: the workshop revealed that tile collision, A* pathfinding, the observation event generator, and the interaction dispatcher were absent from the ticket catalog despite being architectural blockers for almost everything else. These were added at critical priority. + +--- + +## Decisions Confirmed + +The workshop confirmed 6 decisions. These fed directly into later formal D-records: + +| Decision | Formal Record | Notes | +|----------|---------------|-------| +| Deterministic replay is an architectural requirement, not just test infrastructure | D-010 (reinforced) | Promoted #201 to critical | +| Time system: 10 tps, 4 day phases, diegetic clock | D-031 | Resolved Q-009; ticket #25 renamed and promoted | +| Godot test framework = gdUnit4 | process.md | Tyre reversed GUT recommendation after Hoshe's analysis | +| IPC testing = three-layer architecture (fixture, protocol mock, real integration) | architecture.md | Hoshe + Tyre | +| CauseChain is a production component, not test pollution | architecture.md | #269 created | +| Dual Lens Authoring Guide must precede content authoring | D-038 extended | All agents converged | + +These decisions fed into the scope and architecture domain files. The three-tier content system (D-023), vertical slice (D-027), and population ratios (D-029) were validated against the gap analysis findings and confirmed as sound. + +--- + +## Ticket Changes + +**Before:** 232 tickets | **After:** 273 tickets (+41) + +| Change Type | Count | +|-------------|-------| +| Priority promotions (existing) | 15 | +| New epics | 3 | +| New stories | 38 | +| Dependency records added | 21 | +| Renamed + promoted | 1 (#25) | + +### Priority Promotions (15 tickets) + +**Medium → High (11):** #103 (relationship dynamics), #105 (tolerance threshold triggers), #171 (trust-gated gossip), #172 (unprompted disclosure), #173 (trait modifier system), #175 (entanglement ratio), #176 (NPC pool generation), #121 (character voice variation), #126 (medium-range visual indicators), #162 (storyteller module activation), #178 (seed-based variation). + +**High → Critical (3):** #201 (deterministic replay), #182 (divergent starting knowledge), #183 (divergent relationships). + +### New Epics (3) + +| ID | Title | Priority | +|----|-------|----------| +| #233 | Movement & Collision | critical | +| #234 | Observation & Interaction | critical | +| #235 | Game State Management | high | + +### Critical New Stories + +| ID | Title | Priority | +|----|-------|----------| +| #236 | Tile collision system | critical | +| #239 | Observation event generator | critical | +| #240 | Player interaction system and dispatcher | critical | +| #237 | Tile-based A* pathfinding | high | +| #238 | NPC path following and movement | high | +| #253 | Monologue content architecture | high | +| #261 | Dual Lens Authoring Guide | high | +| (+ 31 more stories) | | high/medium/low | + +### Critical Path Produced + +``` +Sprint 1: #236 (collision) → #237 (pathfinding) → #238 (NPC movement) + #25 (game clock) → #88 (daily routines) +Sprint 2: #239 (observation generator) + #240 (interaction dispatcher) +Sprint 3: Observation verbs, NPC conversation, monologue, triangle escalation +Sprint 4: Content pipeline (#261 dual lens guide blocks all content packs) +Sprint 5: Validation and playtest protocol +``` + +--- + +## Open Questions + +None formally raised as Q-records by this workshop — the gap analysis was primarily a ticket and priority exercise. Questions about content authoring (Q-012 through Q-017) were raised in the companion content-gap-analysis workshop the same day. + +--- + +*Compiled by Qatux. Source: `docs/workshops/v01-gap-analysis/si-ticket-changes.md`. Decisions confirmed fed into `decisions/scope.md` (D-023, D-027, D-029) and `decisions/architecture.md` (D-010, D-031).* diff --git a/docs/workshops/wiki-review/workshop-outcomes.md b/docs/workshops/wiki-review/workshop-outcomes.md new file mode 100644 index 000000000..702389a80 --- /dev/null +++ b/docs/workshops/wiki-review/workshop-outcomes.md @@ -0,0 +1,116 @@ +# Workshop Outcomes: Wiki Review & Content Standards + +**Workshop:** Wiki Review & Content Standards +**Date:** 2026-02-12 +**Rounds:** 4 + lead interview between R3 and R4 +**Participants:** Paula, Mellanie, Miri, Gestalt, Gore, Nigel, Ozzie, Tyre, Qatux, Si +**Facilitator:** Jeroen +**Documenter:** Qatux +**Status:** CLOSED — all recoverable decisions filed, tickets created +**Full notes:** `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md` + +--- + +## What the Workshop Accomplished + +Began as a v0.1 wiki review (45 files, ticket #368) and was redirected between rounds 2 and 3 by a major strategic reframe: the lead declared the target as 300 populated worlds before DLC. This transformed the workshop from a content authoring strategy into a generator specification strategy. All Round 4 responses independently arrived at the same conclusion: the workshop had been designing generator specifications all along. The district skeleton, NPC composition matrix, Sacred/Profane framework, and pool architecture were already generator-shaped. + +**The strategic reframe:** Old model: writers produce districts, tooling accelerates writers. New model: engineers produce generators, writers produce generator inputs, tooling IS the product. + +**Note on decision IDs:** Several IDs proposed at this workshop collided with later numbering. Genuinely new decisions identified in retrospect were filed as D-088 (300-world generator model), D-090 (three-tier world authoring), D-092 (Sacred/Profane/Middle Kingdom framework), Q-030, Q-035 through Q-039. + +--- + +## Decisions Produced + +### Long-Term Strategy Decisions (Confirmed by Lead) + +| ID | Decision | Domain | +|----|----------|--------| +| D-088 | 300 worlds before DLC — generator model required | scope.md | +| D-090 | Three-tier world authoring: Landmark (10-15 hand-authored), Regional (30-50 template), Generated (230-260 procedural) | scope.md | +| D-092 | Sacred/Profane/Middle Kingdom randomization framework | architecture.md | + +### Supporting Decisions (Rounds 1-2, v0.1 Specific) + +| ID | Decision | Domain | +|----|----------|--------| +| Q-030 | Cultural ingredients menu: 6 ingredient categories, Heritage OPTIONAL, derivation function produces cultural parameters | pending formalization | +| — | Hael renamed to Naia Tamm (resolves Kael/Hael sonic collision) | content.md | +| — | Three-system NPC architecture: Thematic Patterns + Functional Motivations + Composition rules | content.md | +| — | Cultural brief = generation seed (seed.yaml + brief.md dual artifact) | process.md | +| — | 8 PC archetypes at v1.0; archetypes are FLUID positions, not classes | scope.md | +| — | THE NOBODY: dynamic tier promotion (NOBODY → NOTICED → RECOGNIZED → KNOWN → INVESTED) | scope.md | + +The specific v0.1 decisions (Naia rename, NPC architecture, THE NOBODY, THE MIRROR) were carried forward and filed in the subsequent v0.1 Content Scoping workshop where IDs were formally assigned. + +--- + +## Open Questions Raised + +| ID | Question | Status | +|----|----------|--------| +| Q-030 | Cultural ingredients menu: full formalization of 6 categories, null-heritage behavior, derivation function specification | Pending | +| Q-035 | Naming algorithm: phonetic rules vs word lists — which approach for 300-world cultural naming | Pending | +| Q-036 | Gate topology design: connectivity requirements, hub placement, small-world properties | Pending | +| Q-037 | Storyteller cultural literacy: how storyteller adapts pacing to cultural trust-building rates | Pending | +| Q-038 | NPC pattern composition rules: forbidden/preferred combination formalization | Pending | +| Q-039 | Modding toolkit: content pack manifest, ADD/REPLACE/MERGE overlay operations | Pending | + +--- + +## Tickets Created + +29 new tickets (1 epic, 9 stories, 19 tasks) + 4 existing ticket updates. See `si-ticket-changes.md` for full list. + +**Teams:** copy (21), server (7), ci (1). + +### Existing Ticket Updates (4) + +| ID | Action | +|----|--------| +| #301 | Update description with concrete taxonomy rules from workshop | +| #319 | Update — Miri's Krenn brief supersedes original scope | +| #368 | Mark done — wiki at wiki/ is the delivered output | +| #261 | No change — confirmed still blocking, assign to copy when ready | + +### New Epic + +**Wiki Review Workshop Outputs** — parent epic for all workshop-produced tickets. Priority: high. Team: copy. + +### Selected New Tickets + +| Series | Count | Examples | +|--------|-------|---------| +| A (Wiki content updates) | 12 | A1 canonical names, A2 Hael→Naia rename, A3 Krenn brief, A7 smuggler attributes, A11 Triangle 1 fix | +| B (Style guides + specs) | 5 | B1 NPC Authoring Style Guide, B2 MIRROR spec, B3 PC-as-NPC spec, B4 Smuggler voice card | +| C (Content directory + schemas) | 10 | C1 design doc, C2 directory skeleton, C3 schemas, C9 validate-content CLI | +| D (Design specs) | 4 | D1 cultural_gate design, D2 seed config schema, D3 secondary contraband, D4 news ticker | + +--- + +## Deferred to Future Workshops + +The wiki-review triggered scoping of several follow-on workshops: + +- **Control & Interaction Scheme** — brief written during this workshop; held separately (`docs/workshops/control-interaction/`) +- Perception system gravity variation for world transitions +- Full NPC pattern composition rules (forbidden/preferred lists) +- Gate topology design +- Storyteller cultural literacy +- LLM-assisted content pipeline design +- Modding toolkit specification +- Inter-world political generation + +--- + +## Key Quotes Preserved + +- Nigel: "Two players, same world, same seed, different experience because they noticed different people." +- Gore: "Learning to see is the endgame." +- Gestalt: "Hotline Miami movement + Disco Elysium interaction." +- Gore: "The theme is the field, not any specific phrasing of it. The field is: what does it cost to be human inside something bigger than yourself?" + +--- + +*Compiled by Qatux. Source: `docs/workshops/wiki-review/SUMMARY.md`, `si-ticket-changes.md`. Decisions in `decisions/scope.md` (D-088, D-090) and `decisions/architecture.md` (D-092). Open questions in `decisions/questions.md` (Q-030, Q-035 through Q-039).* diff --git a/project.yaml b/project.yaml index 3f02b4cbc..5b80e1e9b 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ name: The Settled Reach -version: 0.1.15 +version: 0.1.20 repository: settled-reach codename: commonwealth diff --git a/server/Cargo.lock b/server/Cargo.lock index 6b618aa1d..d469ae4e2 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1092,7 +1092,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.15" +version = "0.1.20" dependencies = [ "bevy_app", "bevy_ecs", diff --git a/server/Cargo.toml b/server/Cargo.toml index d3864c8ce..1140e6606 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "settled-reach-server" -version = "0.1.15" +version = "0.1.20" edition = "2021" [dependencies] @@ -24,3 +24,13 @@ gauntlet = [] [dev-dependencies] serde_json = "1" + +# --------------------------------------------------------------------------- +# Explicit test target for the Layer 3 integration module (D-030, ticket #200). +# tests/integration/mod.rs cannot be auto-discovered by cargo (only top-level +# *.rs files in tests/ are auto-discovered). This declaration makes it a named +# test binary: `cargo test --test integration_layer3`. +# --------------------------------------------------------------------------- +[[test]] +name = "integration_layer3" +path = "tests/integration/mod.rs" diff --git a/server/data/templates/dock-worker.yaml b/server/data/templates/dock-worker.yaml new file mode 100644 index 000000000..b704aeced --- /dev/null +++ b/server/data/templates/dock-worker.yaml @@ -0,0 +1,31 @@ +# Sample role schema for the dock-worker role in the terminal social site. +# Spec ref: D-023 (Tier 2 templates), D-024 (10-axis model), D-025 (social site) +# +# Tile scale note (D-066): tile_count_* fields are in SIM tiles (0.5m each). +# 1 visual tile = 2 sim tiles. A 15-40 visual tile space = 30-80 sim tiles. + +role_id: "dock-worker" +required_traits: + - Honest + - Social +skill_focus: + - Technical + - Observation +relationship_constraints: + - with_role: "logistics-manager" + kind: Colleague + required_trust: + min: -2 + max: 4 + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -4 + max: 0 +routine_template: + - phase: morning + location: "terminal-cargo-bay" + - phase: afternoon + location: "terminal-cargo-bay" + - phase: evening + location: "bar-last-shift" diff --git a/server/data/templates/logistics-hub.yaml b/server/data/templates/logistics-hub.yaml new file mode 100644 index 000000000..d256adbbe --- /dev/null +++ b/server/data/templates/logistics-hub.yaml @@ -0,0 +1,202 @@ +# Sova Station Logistics Hub — Tier 2 social site template (#159). +# +# Spec refs: D-023 (three-tier content model), D-024 (10-axis NPC model), +# D-025 (social site as atomic template unit), D-028 (dialogue pools), +# D-087 (v0.1 triangle configuration), D-089 (self-contained forks) +# +# Tile scale (D-066): tile_count_* are SIM tiles (0.5m each). +# 15–40 visual tiles (D-025) = 30–80 sim tiles. +# +# triangle_id values are authoring placeholders (0). +# The instantiation engine overwrites them with +# TriangleId::from_seed_and_roles(world_seed, &roles) at runtime. + +slug: "logistics-hub" +display_name: "Sova Station Logistics Hub" +description: > + The terminal's operational core — cargo processing, documentation, + and the informal relationships that keep goods moving off-manifest. + A functional cluster of four NPCs controlling access to the station's + primary freight throughput. + +roles: + - role_id: "logistics-manager" + required_traits: + - Bold + - Cautious + skill_focus: + - Persuasion + - Observation + relationship_constraints: + - with_role: "dock-worker" + kind: Superior + required_trust: + min: 0 + max: 5 + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -5 + max: 2 + - with_role: "security-guard" + kind: Superior + required_trust: + min: 1 + max: 5 + routine_template: + - phase: morning + location: "terminal-office" + activity: "documentation-review" + - phase: afternoon + location: "terminal-cargo-bay" + activity: "cargo-inspection" + - phase: evening + location: "terminal-office" + activity: "end-of-day-reports" + + - role_id: "dock-worker" + required_traits: + - Honest + - Social + skill_focus: + - Technical + - Observation + relationship_constraints: + - with_role: "logistics-manager" + kind: Subordinate + required_trust: + min: -2 + max: 4 + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -4 + max: 0 + routine_template: + - phase: morning + location: "terminal-cargo-bay" + activity: "freight-handling" + - phase: afternoon + location: "terminal-cargo-bay" + activity: "freight-handling" + - phase: evening + location: "bar-last-shift" + + - role_id: "ring-contact" + required_traits: + - Deceptive + - Social + skill_focus: + - Stealth + - Persuasion + relationship_constraints: + - with_role: "logistics-manager" + kind: Colleague + required_trust: + min: -5 + max: 2 + - with_role: "dock-worker" + kind: Colleague + required_trust: + min: -4 + max: 0 + routine_template: + - phase: morning + location: "terminal-cargo-bay" + activity: "oversight" + - phase: afternoon + location: "maintenance-corridor" + - phase: evening + location: "bar-last-shift" + + - role_id: "security-guard" + required_traits: + - Cautious + - Honest + skill_focus: + - Combat + - Observation + relationship_constraints: + - with_role: "logistics-manager" + kind: Subordinate + required_trust: + min: 1 + max: 5 + routine_template: + - phase: morning + location: "terminal-entrance" + activity: "access-control" + - phase: afternoon + location: "terminal-cargo-bay" + activity: "patrol" + - phase: evening + location: "terminal-entrance" + activity: "access-control" + +space: + tile_count_min: 30 + tile_count_max: 80 + sightline_zones: + - name: "loading-floor" + radius: 8 # 4m clear sightline across the loading area + - name: "reception-desk" + radius: 4 # 2m clear sightline at the desk + - name: "cargo-staging" + radius: 6 # 3m clear sightline in staging area + privacy_level: SemiPrivate + traffic_pattern: Destination + +triangles: + - triangle_id: 0 + roles: + - "ring-contact" + - "dock-worker" + - "logistics-manager" + conflict_type: ResourceCompetition + interest_axes: + - Want + - Secret + - Relationships + relationship_constraints: + - with_role: "dock-worker" + kind: Subordinate + required_trust: + min: -2 + max: 2 + + - triangle_id: 0 + roles: + - "logistics-manager" + - "security-guard" + - "ring-contact" + conflict_type: AuthorityChallenge + interest_axes: + - Want + - Tolerance + - Secret + relationship_constraints: + - with_role: "security-guard" + kind: Superior + required_trust: + min: 0 + max: 5 + +dialogue_pools: + - location: "the-terminal" + roles: + - "logistics-manager" + - "dock-worker" + - "ring-contact" + - "security-guard" + - location: "terminal-cargo-bay" + roles: + - "dock-worker" + - "ring-contact" + +cross_template_links: + - from_role: "dock-worker" + to_template_slug: "last-shift-bar" + relationship: Colleague + - from_role: "ring-contact" + to_template_slug: "last-shift-bar" + relationship: Colleague diff --git a/server/data/templates/terminal-social-site.yaml b/server/data/templates/terminal-social-site.yaml new file mode 100644 index 000000000..0b9866f13 --- /dev/null +++ b/server/data/templates/terminal-social-site.yaml @@ -0,0 +1,18 @@ +# Sample space spec for the terminal social site (Sova Logistics Hub). +# Spec ref: D-025 (15-40 visual tiles = 30-80 sim tiles), D-064 (dual-scale grid) +# +# Tile scale (D-066): all tile counts are SIM tiles (0.5m each). +# Terminal: 44×28 visual tiles = 88×56 sim tiles = 4928 sim tiles. +# Using a subsection for one functional zone: ~44×8 visual = 88×16 sim = 1408 sim. + +tile_count_min: 30 +tile_count_max: 80 +sightline_zones: + - name: "loading-floor" + radius: 8 # 4m clear sightline across the loading area + - name: "reception-desk" + radius: 4 # 2m clear sightline at the desk + - name: "cargo-staging" + radius: 6 # 3m clear sightline in staging area +privacy_level: SemiPrivate +traffic_pattern: Destination diff --git a/server/data/templates/terminal-triangle-01.yaml b/server/data/templates/terminal-triangle-01.yaml new file mode 100644 index 000000000..8764546b5 --- /dev/null +++ b/server/data/templates/terminal-triangle-01.yaml @@ -0,0 +1,24 @@ +# Sample triangle definition: T4 Drin-System-Ring (D-087 active fork). +# Spec ref: D-024 (triangles as atomic social unit), D-087 (T4 configuration), +# D-089 (self-contained, no cross-triangle cascade) +# +# Note: triangle_id is computed at world-gen time from seed + roles. +# The value below is a placeholder for YAML authoring — the runtime +# calls TriangleId::from_seed_and_roles() to derive the actual ID. + +triangle_id: 0 +roles: + - "ring-leader" + - "dock-worker" + - "logistics-manager" +conflict_type: ResourceCompetition +interest_axes: + - Want + - Secret + - Relationships +relationship_constraints: + - with_role: "dock-worker" + kind: Subordinate + required_trust: + min: -2 + max: 2 diff --git a/server/src/bridge/local.rs b/server/src/bridge/local.rs index af5b395b3..61ab95073 100644 --- a/server/src/bridge/local.rs +++ b/server/src/bridge/local.rs @@ -80,6 +80,21 @@ impl LocalBridge { } impl SimBridge for LocalBridge { + fn send_handshake(&self) -> Result<(), BridgeError> { + use super::types::{HandshakeMessage, PROTOCOL_VERSION}; + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let payload = rmp_serde::to_vec_named(&msg)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + write_framed(writer.get_mut(), &payload)?; + tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION); + Ok(()) + } + fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> { let payload = rmp_serde::to_vec_named(snapshot)?; diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index a09fcaf8f..a02450ec2 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -35,6 +35,11 @@ pub enum BridgeError { /// Abstracts transport layer (D-020) /// Implemented by LocalBridge (stdio) and future NetworkBridge pub trait SimBridge: Send + Sync { + /// Send the protocol handshake as the first framed message (#555). + /// Must be called exactly once, immediately after connection, before + /// any ObserverSnapshot is sent. + fn send_handshake(&self) -> Result<(), BridgeError>; + /// Send an observer snapshot to the client fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>; @@ -55,6 +60,10 @@ impl BridgeResource { } } + pub fn send_handshake(&self) -> Result<(), BridgeError> { + self.inner.send_handshake() + } + pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> { self.inner.send_snapshot(snapshot) } @@ -64,15 +73,46 @@ impl BridgeResource { } } -/// Receive inputs from bridge and push to InputQueue +/// Tracks whether the protocol handshake has been sent (#555). +/// Inserted by BridgePlugin as Pending. Set to Complete in main.rs after +/// `send_handshake()` succeeds. `receive_bridge_inputs` logs a warning +/// if inputs arrive while still Pending. +#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandshakeState { + /// Handshake not yet sent. Inputs arriving in this state trigger a warning. + Pending, + /// Handshake sent. Normal operation. + Complete, +} + +impl Default for HandshakeState { + fn default() -> Self { + Self::Pending + } +} + +/// Receive inputs from bridge and push to InputQueue. +/// Protocol errors (malformed input) are recoverable: the frame is skipped +/// and a SimError is pushed to the SimErrorBuffer for client reporting (#85). pub fn receive_bridge_inputs( bridge: Option<Res<BridgeResource>>, mut input_queue: ResMut<crate::simulation::input::InputQueue>, mut running: ResMut<ServerRunning>, + handshake: Res<HandshakeState>, + mut error_buffer: ResMut<SimErrorBuffer>, + time: Option<Res<crate::simulation::time::SimulationTime>>, ) { let Some(bridge) = bridge else { return }; + let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0); + match bridge.receive_inputs() { Ok(inputs) => { + if !inputs.is_empty() && *handshake == HandshakeState::Pending { + tracing::warn!( + "Received {} input(s) before handshake completed — processing anyway (forward-compatible)", + inputs.len() + ); + } for input in &inputs { tracing::trace!( "Received input: tick={} action={:?}", @@ -100,8 +140,22 @@ pub fn receive_bridge_inputs( running.0 = false; } Err(BridgeError::DeserializationWithDump(ref msg)) => { - // Recoverable: skip this frame's input, don't shut down + // Recoverable: skip this frame's input, report to client (#85) tracing::error!("Skipping malformed input frame: {}", msg); + error_buffer.push(SimError { + kind: SimErrorKind::ProtocolError, + message: format!("Malformed input frame: {}", msg), + tick: current_tick, + }); + } + Err(ref e @ BridgeError::Deserialization(_)) => { + // Recoverable deserialization error without dump + tracing::error!("Skipping malformed input: {}", e); + error_buffer.push(SimError { + kind: SimErrorKind::ProtocolError, + message: format!("Deserialization error: {}", e), + tick: current_tick, + }); } Err(e) => { tracing::error!("Bridge receive error: {}", e); @@ -156,6 +210,8 @@ impl Plugin for BridgePlugin { fn build(&self, app: &mut App) { app.init_resource::<SnapshotBuffer>() .init_resource::<ServerRunning>() + .init_resource::<HandshakeState>() + .init_resource::<SimErrorBuffer>() .init_resource::<crate::perception::query::VisibilityGeometry>() .init_resource::<crate::perception::query::ActivePerceptionMode>() .add_systems( diff --git a/server/src/bridge/tcp.rs b/server/src/bridge/tcp.rs index d4ddf2a88..0321fc829 100644 --- a/server/src/bridge/tcp.rs +++ b/server/src/bridge/tcp.rs @@ -45,7 +45,7 @@ impl TcpBridge { ); // Set non-blocking so receive_inputs doesn't stall the game loop. - // read_framed handles WouldBlock by returning Ok(None). + // receive_inputs catches WouldBlock from read_framed and returns Ok(vec![]). stream .set_nonblocking(true) .map_err(|e| BridgeError::Transport(format!("failed to set non-blocking: {}", e)))?; @@ -129,6 +129,26 @@ impl TcpBridge { } impl SimBridge for TcpBridge { + fn send_handshake(&self) -> Result<(), BridgeError> { + use super::types::{HandshakeMessage, PROTOCOL_VERSION}; + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let payload = rmp_serde::to_vec_named(&msg)?; + let mut writer = self + .writer + .lock() + .map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?; + // Toggle to blocking for reliable handshake delivery. + let stream = writer.get_mut(); + stream.set_nonblocking(false).map_err(BridgeError::Io)?; + let result = write_framed(stream, &payload); + stream.set_nonblocking(true).map_err(BridgeError::Io)?; + result?; + tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION); + Ok(()) + } + fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> { let payload = rmp_serde::to_vec_named(snapshot)?; diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index d03a30912..1339302b5 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -306,8 +306,16 @@ mod tests { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, sound_events: vec![], rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -436,8 +444,16 @@ mod tests { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, sound_events: vec![], rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 8c2697358..f6f780125 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -5,7 +5,9 @@ use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; -pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState}; +pub use crate::knowledge::types::{ + EntityVisibility, KnowledgeConfidence, KnowledgeState, RelationshipState, +}; pub use crate::simulation::time::{DayPhase, TickRate}; /// Wire protocol version for ObserverSnapshot. @@ -15,7 +17,17 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 13; +pub const PROTOCOL_VERSION: u8 = 17; + +/// Handshake message sent as the very first framed message after connection (#555). +/// Client reads this before entering the normal tick loop and validates +/// `protocol_version` against its own `PROTOCOL_VERSION` constant. +/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HandshakeMessage { + /// Must match client's PROTOCOL_VERSION or the client should disconnect. + pub protocol_version: u8, +} /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -34,10 +46,17 @@ pub const PROTOCOL_VERSION: u8 = 13; /// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations). /// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use), /// follow_state (#241, follow mechanic HUD state). +/// v14 adds: poi_list (#151, discovered POIs for minimap rendering), +/// examine_result (#242, character-filtered examine observation text), +/// player_knowledge (#264, partial KG dump for journal/knowledge panel). +/// v15 adds: save_result (#553, save/load operation result for client confirmation). +/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering). +/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick), +/// sim_errors (#85, structured error reporting to client). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 13. + /// Protocol version for forward compatibility. Current: 17. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -105,11 +124,54 @@ pub struct ObserverSnapshot { /// Client shows follow indicator with distance, LOS, and tension. #[serde(default)] pub follow_state: Option<crate::simulation::follow::FollowStateWire>, + /// Character pressure state for client HUD widget (#248). + /// Present when pressure is non-zero. Client renders tension indicator. + #[serde(default)] + pub character_pressure: Option<crate::simulation::pressure::CharacterPressureWire>, /// RNG seed active at this tick for deterministic replay (#527). /// The WRONG button writes this to seed.txt so replays reproduce observed bugs. /// None when the RNG resource is unavailable (should not occur in practice). #[serde(default, skip_serializing_if = "Option::is_none")] pub rng_seed: Option<u64>, + /// Discovered POIs for minimap rendering (#151, D-013). + /// Contains all POIs the observer has discovered (fact in KG). + /// Client renders nearby POIs as dots, distant POIs as directional arrows. + /// Empty when no POIs have been discovered. + #[serde(default)] + pub poi_list: Vec<PoiWire>, + /// Character-filtered observation text from Examine verb (#174, #242). + /// Present when an examine interaction completed this tick. + /// Client displays as non-interactive overlay, auto-dismisses after 4-6 seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub examine_result: Option<ExamineResultWire>, + /// Partial knowledge graph dump for journal/knowledge panel (#264, D-041). + /// Updated periodically (not every tick — only when KG changes). + /// Client renders as a read-only journal grouped by entity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_knowledge: Option<PlayerKnowledgeWire>, + /// Result of the most recently completed save or load (#553, D-085). + /// Present for exactly one tick after the operation completes. + /// Client shows a confirmation toast (success) or error modal (failure). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub save_result: Option<SaveLoadResultWire>, + /// Triangle crisis events this tick (#250, D-087). + /// Emitted when a triangle enters Active phase. Client may render + /// a narrative event or HUD indicator. Empty when no crises occur. + #[serde(default)] + pub triangle_crisis_events: Vec<TriangleCrisisEventWire>, + /// Fast hash of key mutable state for desync detection (#85). + /// Hash inputs: player position, NPC count, tick number. + /// Client compares against its own computed hash — mismatch indicates + /// client and server state have diverged. No auto-recovery in v0.1; + /// client logs mismatches for debugging. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_hash: Option<u64>, + /// Simulation errors reported this tick (#85). + /// Non-fatal errors (protocol errors, desync) are collected during + /// the tick and sent to the client for logging/display. + /// Empty in normal operation. Client may display a warning toast. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sim_errors: Vec<SimError>, } /// Game time data for client display (D-031) @@ -384,6 +446,14 @@ pub enum PlayerAction { target_entity_id: u64, response_id: String, }, + /// Save the current game state to `path` (#553, D-085). + /// Client sends this when the player activates the save UI. + /// Server executes save_to_file and sends SaveLoadResultWire confirmation. + SaveGame { path: String }, + /// Load a previously saved game from `path` (#553, D-085). + /// Client sends this when the player selects a save file to load. + /// Server executes load_from_file and sends SaveLoadResultWire confirmation. + LoadGame { path: String }, } impl PlayerAction { @@ -536,8 +606,223 @@ pub struct DialogueResponseEvent { pub speaker_name: String, } +/// A discovered POI crossing the wire for minimap rendering (#151). +/// Derived from PointOfInterest component + KG fact lookup. +/// Client converts position to player-relative vector for minimap display. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PoiWire { + /// POI identifier (matches poi_id in PointOfInterest component). + pub poi_id: String, + /// Display name for minimap label. + pub name: String, + /// World position in simulation tile coordinates. + /// Client converts to player-relative vector for compass placement. + pub x: i32, + pub y: i32, + pub z: i32, + /// Category for icon/color selection on minimap. + pub category: crate::simulation::poi::PoiCategory, +} + +/// Character-filtered observation text from Examine verb (#174, #242). +/// The server runs the examine through the observer's KG to produce +/// text appropriate to what the character knows/sees. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExamineResultWire { + /// Wire-format entity ID of the examined entity. + pub entity_id: u64, + /// Character-filtered observation text. + pub text: String, + /// Observer's confidence level about this entity at time of examine. + pub confidence: KnowledgeConfidence, +} + +/// Partial knowledge graph dump for the journal panel (#264, D-041). +/// Sent when KG state changes. Contains entity knowledge and fact knowledge +/// that the observer has accumulated. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayerKnowledgeWire { + /// Known entities with their knowledge metadata. + pub entities: Vec<KnownEntityWire>, + /// Known facts (non-entity knowledge: POIs, events, abstract info). + pub facts: Vec<KnownFactWire>, +} + +/// A single entity knowledge entry for the journal wire format (#264). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnownEntityWire { + /// Wire-format entity ID. + pub entity_id: u64, + /// Display name (from known_attributes if available, else "Unknown"). + pub name: String, + /// Confidence level (Suspects / KnowsOf / KnowsDetails / Direct). + pub confidence: KnowledgeConfidence, + /// How the knowledge was acquired. + pub source: String, + /// Logical state (Active / Contradicted / Stale). + pub state: KnowledgeState, + /// Relationship assessment for color rendering. + pub relationship: RelationshipState, + /// Last tick this entity was observed. + pub last_observed_tick: u64, +} + +/// A single fact knowledge entry for the journal wire format (#264). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnownFactWire { + /// Fact identifier (e.g. "poi.docking_bay_7", "contraband.ring_exists"). + pub fact_id: String, + /// Confidence level. + pub confidence: KnowledgeConfidence, + /// How the fact was acquired. + pub source: String, + /// Logical state. + pub state: KnowledgeState, + /// Tick when this fact was learned. + pub acquired_tick: u64, +} + +/// Save/load operation result for client confirmation (#553, D-085). +/// +/// Included in `ObserverSnapshot.save_result` for exactly one tick after the +/// operation completes. `success=false` carries a human-readable `error` string. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveLoadResultWire { + /// Whether the save or load succeeded. + pub success: bool, + /// "save" or "load" — identifies which operation completed. + pub kind: String, + /// Error message if `success` is false. None on success. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +/// Triangle crisis event for future client rendering (#250, D-087). +/// +/// Emitted when a triangle transitions from Simmering to Active. +/// Client may display a narrative beat, HUD indicator, or tension meter. +/// Wire format uses primitives for cross-boundary safety. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TriangleCrisisEventWire { + /// Triangle identifier (TriangleId as u64). + pub triangle_id: u64, + /// NPC role assignments: (role_slug, stable_npc_id). + pub role_assignments: Vec<(String, u64)>, + /// The NPC whose tolerance threshold triggered the crisis. + pub trigger_npc_id: u64, + /// Tick when the crisis was triggered. + pub tick: u64, +} + +impl From<crate::content::template::TriangleCrisisEvent> for TriangleCrisisEventWire { + fn from(e: crate::content::template::TriangleCrisisEvent) -> Self { + Self { + triangle_id: e.triangle_id.into(), + role_assignments: e + .role_assignments + .into_iter() + .map(|(role, sid)| (String::from(role), u64::from(sid))) + .collect(), + trigger_npc_id: e.trigger_npc.into(), + tick: e.tick, + } + } +} + +/// Structured simulation error for client reporting (#85). +/// +/// Sent inside `ObserverSnapshot.sim_errors` for recoverable errors +/// (protocol errors, desync warnings). For fatal errors (panics), +/// a final snapshot is sent with the error before the server exits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimError { + /// Error category for client-side handling. + pub kind: SimErrorKind, + /// Human-readable error description. + pub message: String, + /// Tick when the error occurred (0 if unavailable). + pub tick: u64, +} + +/// Categories of simulation errors (#85). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SimErrorKind { + /// Simulation system panic — fatal, server will exit after sending this. + Panic, + /// Protocol/deserialization error — recoverable, server continues. + ProtocolError, + /// Client-server state hash mismatch — informational, no auto-recovery. + DesyncDetected, +} + +/// Buffer for collecting simulation errors during a tick (#85). +/// Drained by `compute_observer_snapshot` into `ObserverSnapshot.sim_errors`. +#[derive(Resource, Debug, Default)] +pub struct SimErrorBuffer { + errors: Vec<SimError>, +} + +impl SimErrorBuffer { + /// Push a new error into the buffer. + pub fn push(&mut self, error: SimError) { + self.errors.push(error); + } + + /// Drain all buffered errors, returning them and clearing the buffer. + pub fn drain(&mut self) -> Vec<SimError> { + std::mem::take(&mut self.errors) + } + + /// Check if there are pending errors. + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { pub snapshot: Option<ObserverSnapshot>, + /// Pending save/load result, consumed once by `compute_observer_snapshot` (#553). + pub pending_save_result: Option<SaveLoadResultWire>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handshake_message_roundtrip() { + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(decoded, msg); + assert_eq!(decoded.protocol_version, PROTOCOL_VERSION); + } + + #[test] + fn handshake_message_rejects_wrong_version() { + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize"); + // Simulate client-side validation: version mismatch should be detectable + let wrong_version = PROTOCOL_VERSION.wrapping_add(1); + assert_ne!(decoded.protocol_version, wrong_version); + } + + #[test] + fn handshake_is_distinct_from_snapshot() { + // HandshakeMessage and ObserverSnapshot are different types on the wire. + // A HandshakeMessage should NOT deserialize as an ObserverSnapshot. + let msg = HandshakeMessage { + protocol_version: PROTOCOL_VERSION, + }; + let bytes = rmp_serde::to_vec_named(&msg).expect("serialize"); + let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes); + assert!(result.is_err(), "HandshakeMessage must not deserialize as ObserverSnapshot"); + } } diff --git a/server/src/content/instantiation.rs b/server/src/content/instantiation.rs new file mode 100644 index 000000000..88ebc9b5b --- /dev/null +++ b/server/src/content/instantiation.rs @@ -0,0 +1,214 @@ +//! Template instantiation engine (#161). +//! +//! Wires the full pipeline: `FullTemplateDef` → NPC spawn (via spawn.rs) → +//! triangle generation (via template.rs) → instance tracking. +//! +//! **Pipeline:** +//! 1. Validate the `FullTemplateDef` (schema-level checks). +//! 2. Call `spawn_template_npcs` to create NPC entities and wire relationships. +//! 3. Call `generate_intra_template_triangles` to generate `TriangleState` values. +//! 4. Spawn each `TriangleState` as an ECS entity with the `ActiveSim` marker. +//! 5. Register the live instance in `ActiveTemplateInstances`. +//! +//! **Instance lifecycle:** +//! Instances are tracked by `TemplateId` in `ActiveTemplateInstances`. +//! `unload_template` despawns all NPC and triangle entities and removes the +//! entry from `ActiveTemplateInstances`. +//! +//! **Determinism (D-010):** given the same `FullTemplateDef`, `TemplateId`, +//! `world_seed`, and `SimRng` state, the spawned NPC and triangle layout is +//! identical. + +use std::collections::BTreeMap; + +use bevy_ecs::prelude::*; + +use crate::content::spawn::spawn_template_npcs; +use crate::content::template::{ + generate_intra_template_triangles, FullTemplateDef, TemplateId, +}; +use crate::simulation::rng::SimRng; +use crate::simulation::tier::ActiveSim; + +// =========================================================================== +// Public types +// =========================================================================== + +/// A live template instance — the result of `instantiate_template`. +/// +/// Holds entity handles for all NPCs and triangle entities spawned from a +/// single `FullTemplateDef`. Required by `unload_template` to despawn them. +#[derive(Debug, Clone)] +pub struct TemplateInstance { + /// Template this instance was created from. + pub template_id: TemplateId, + /// ECS entities for the NPC role slots (one per `RoleSchema`). + pub npc_entities: Vec<Entity>, + /// ECS entities for the generated `TriangleState` components. + pub triangle_entities: Vec<Entity>, + /// Non-fatal warnings from triangle generation (e.g., fallback assignments). + pub warnings: Vec<String>, +} + +/// Resource tracking all currently active template instances. +/// +/// Key = `TemplateId.0` (deterministic u64). Initialized on demand by +/// `instantiate_template`; may also be initialized explicitly with +/// `world.init_resource::<ActiveTemplateInstances>()`. +/// +/// **Determinism (D-010):** `BTreeMap` for consistent iteration order. +#[derive(Resource, Default, Debug)] +pub struct ActiveTemplateInstances { + instances: BTreeMap<u64, TemplateInstance>, +} + +impl ActiveTemplateInstances { + /// Register a new instance. Overwrites any existing entry for the same ID. + pub fn insert(&mut self, instance: TemplateInstance) { + self.instances.insert(instance.template_id.0, instance); + } + + /// Look up a live instance by template ID. + pub fn get(&self, template_id: TemplateId) -> Option<&TemplateInstance> { + self.instances.get(&template_id.0) + } + + /// Remove and return an instance (used by `unload_template`). + pub fn remove(&mut self, template_id: TemplateId) -> Option<TemplateInstance> { + self.instances.remove(&template_id.0) + } + + /// Number of active instances. + pub fn len(&self) -> usize { + self.instances.len() + } + + /// `true` if no instances are active. + pub fn is_empty(&self) -> bool { + self.instances.is_empty() + } +} + +// =========================================================================== +// Instantiation +// =========================================================================== + +/// Instantiate a template: validate, spawn NPCs, generate triangles, register. +/// +/// **Preconditions:** +/// - `EntityRegistry` must be initialized as a world resource (done by +/// `SimulationPlugin` at startup). +/// - `ActiveTemplateInstances` is initialized on demand inside this function. +/// +/// **Returns** the created `TemplateInstance` (also stored in +/// `ActiveTemplateInstances`). +/// +/// **Errors:** returns `Err(String)` if `template_def.validate()` fails. +pub fn instantiate_template( + world: &mut World, + template_def: &FullTemplateDef, + template_id: TemplateId, + world_seed: u64, + rng: &mut SimRng, +) -> Result<TemplateInstance, String> { + // Schema validation before any ECS mutations. + template_def.validate()?; + + // Phases 1–3: NPC spawn + relationship wiring + cross-template ref map. + let spawn_result = spawn_template_npcs(world, template_def, template_id, world_seed, rng); + + // Phase 4: Generate intra-template triangle state values. + let tri_result = + generate_intra_template_triangles(world, template_id, &template_def.triangles, rng); + + let warnings = tri_result.warnings; + + // Spawn each TriangleState as a dedicated ECS entity with ActiveSim so + // the escalation system can pick it up (D-087). + let triangle_entities: Vec<Entity> = tri_result + .triangles + .into_iter() + .map(|state| world.spawn((ActiveSim, state)).id()) + .collect(); + + let instance = TemplateInstance { + template_id, + npc_entities: spawn_result.entities, + triangle_entities, + warnings, + }; + + // Register in ActiveTemplateInstances (init if absent). + // If a previous instance with the same ID exists, unload it first to + // prevent orphaned ECS entities (Hoshe review #2). + world.init_resource::<ActiveTemplateInstances>(); + let previous = world + .resource_mut::<ActiveTemplateInstances>() + .remove(template_id); + if let Some(prev) = previous { + tracing::warn!( + "instantiate_template: overwriting live TemplateId({}) — despawning {} entities", + template_id.0, + prev.npc_entities.len() + prev.triangle_entities.len(), + ); + for entity in prev.npc_entities.iter().chain(prev.triangle_entities.iter()) { + if world.get_entity(*entity).is_ok() { + world.despawn(*entity); + } + } + } + world + .resource_mut::<ActiveTemplateInstances>() + .insert(instance.clone()); + + Ok(instance) +} + +// =========================================================================== +// Lifecycle: unload +// =========================================================================== + +/// Unload a template instance: despawn all entities and remove from tracking. +/// +/// No-op (with a warning log) if the given `template_id` is not active. +pub fn unload_template(world: &mut World, template_id: TemplateId) { + let instance = world + .resource_mut::<ActiveTemplateInstances>() + .remove(template_id); + + let Some(instance) = instance else { + tracing::warn!( + "unload_template: TemplateId({}) not active — no-op", + template_id.0 + ); + return; + }; + + let mut despawned = 0usize; + for entity in instance.npc_entities.iter().chain(instance.triangle_entities.iter()) { + if world.get_entity(*entity).is_ok() { + world.despawn(*entity); + despawned += 1; + } + } + + tracing::info!( + "unload_template: TemplateId({}) unloaded — {} entities despawned", + template_id.0, + despawned, + ); +} + +// =========================================================================== +// YAML loader +// =========================================================================== + +/// Load a `FullTemplateDef` from a YAML file on disk. +/// +/// Returns `Err(String)` if the file cannot be read or fails YAML parsing. +pub fn load_template_from_file(path: &std::path::Path) -> Result<FullTemplateDef, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read {:?}: {}", path, e))?; + serde_yaml::from_str::<FullTemplateDef>(&content) + .map_err(|e| format!("failed to parse {:?}: {}", path, e)) +} diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index dd150c73e..aefd49acf 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -11,9 +11,11 @@ //! handles the mapping between the two representations. pub mod hot_reload; +pub mod instantiation; pub mod line_pool; pub mod loader; pub mod spawn; +pub mod template; pub mod types; use bevy_app::prelude::*; diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 43d6fe09f..7b63e3400 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -34,6 +34,15 @@ use crate::simulation::movement::TilePosition; use crate::simulation::tier::ActiveSim; use crate::simulation::time::DayPhase; +// #166 — Template-to-instance mapping +use rand::Rng as _; +use crate::content::template::{ + FullTemplateDef, RoleId, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap, +}; +use crate::npc::generate::{generate_npc, RoleDefinition}; +use crate::npc::{Relationship, Relationships}; +use crate::simulation::rng::SimRng; + /// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn"). /// /// Bridges authoring identity to ECS entities. Independent of StableId — @@ -205,6 +214,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // TODO: CombatCapability — no content schema type exists yet. When combat content // is authored, add weapon_proficiency + combat_style mapping here. + // Vision + awareness components (#115, #244) — must match generate_npc(). + // Without these, vision/awareness systems silently skip content-spawned NPCs. + entity_commands.insert(( + npc::vision::NpcVisionState::default(), + npc::vision::NpcMemory::default(), + npc::awareness::PlayerAwareness::default(), + )); + let entity = entity_commands.id(); // Register in EntityRegistry for StableId mapping @@ -648,6 +665,160 @@ pub fn parse_day_phase(s: &str) -> Option<DayPhase> { } } +// =========================================================================== +// #166 — Template-to-instance mapping +// =========================================================================== + +/// Result of spawning all NPC role slots for a template. +#[derive(Debug)] +pub struct TemplateSpawnResult { + /// Stable ID assigned to each role slot. BTreeMap for deterministic ordering (D-010). + pub role_assignments: BTreeMap<RoleId, StableId>, + /// ECS entity handles in the same order as `FullTemplateDef::roles`. + pub entities: Vec<Entity>, +} + +/// Spawn NPC entities for all role slots in a `FullTemplateDef` (#166). +/// +/// Three-phase process: +/// +/// **Phase 1 — Spawn:** For each `RoleSchema`, build a `RoleDefinition` and +/// call `generate_npc()`. Register the entity in `EntityRegistry`, then +/// insert `StableEntityId` + `TemplateOwnership`. +/// +/// **Phase 2 — Relationships:** Wire intra-template `RelationshipConstraint`s. +/// Each constraint becomes a `Relationship` entry on the NPC, with trust +/// sampled within `[min, max]` via `rng`. +/// +/// **Phase 3 — Reference map:** Record cross-template links in +/// `TemplateReferenceMap`, resolving `to_template_slug` to `TemplateId` +/// via `TemplateId::from_seed_and_slug(world_seed, slug)`. +/// +/// **Caller precondition:** `EntityRegistry` must be initialized as a world +/// resource (done by `SimulationPlugin`). `TemplateReferenceMap` is +/// initialized inside this function if absent. +/// +/// **Determinism (D-010):** All randomness flows through `rng`. Same seed +/// and `FullTemplateDef` → same NPC layout every time. +pub fn spawn_template_npcs( + world: &mut World, + template_def: &FullTemplateDef, + template_id: TemplateId, + world_seed: u64, + rng: &mut SimRng, +) -> TemplateSpawnResult { + let mut role_assignments: BTreeMap<RoleId, StableId> = BTreeMap::new(); + let mut role_entities: BTreeMap<RoleId, Entity> = BTreeMap::new(); + let mut entities: Vec<Entity> = Vec::new(); + + // ----------------------------------------------------------------------- + // Phase 1: Spawn one NPC per role slot + // ----------------------------------------------------------------------- + for role_schema in &template_def.roles { + // Enable combat capability for roles whose skill focus includes Combat. + let combat_enabled = role_schema.skill_focus.contains(&crate::npc::Skill::Combat); + + let role_def = RoleDefinition { + name: role_schema.role_id.0.clone(), + // Location pool is empty at template-def time — positions are resolved + // when the template is placed in the world (#161). + location_pool: vec![], + // Relationship targets are empty — Phase 2 wires them from constraints. + relationship_targets: vec![], + known_facts: vec![], + skill_focus: role_schema.skill_focus.clone(), + combat_enabled, + }; + + let entity = generate_npc(&role_def, world, rng); + + // Vision + awareness components (#66) — must match spawn_npc(). + // Without these, vision/awareness systems silently skip template-spawned NPCs. + world.entity_mut(entity).insert(( + crate::npc::vision::NpcVisionState::default(), + crate::npc::vision::NpcMemory::default(), + crate::npc::awareness::PlayerAwareness::default(), + )); + + // Register the entity in EntityRegistry and attach stable identity. + let stable_id = world.resource_mut::<EntityRegistry>().register(entity); + world.entity_mut(entity).insert(( + StableEntityId(stable_id), + TemplateOwnership { + template_id, + role_id: role_schema.role_id.clone(), + }, + )); + + role_assignments.insert(role_schema.role_id.clone(), stable_id); + role_entities.insert(role_schema.role_id.clone(), entity); + entities.push(entity); + } + + // ----------------------------------------------------------------------- + // Phase 2: Wire intra-template relationship constraints + // ----------------------------------------------------------------------- + for role_schema in &template_def.roles { + let Some(&from_entity) = role_entities.get(&role_schema.role_id) else { + continue; + }; + + for constraint in &role_schema.relationship_constraints { + let Some(&with_stable_id) = role_assignments.get(&constraint.with_role) else { + // Referenced role is not in this template — cross-template links + // are handled via TemplateReferenceMap (Phase 3), not Relationships. + tracing::debug!( + "spawn_template_npcs: constraint references role '{}' not in template '{}', skipping", + constraint.with_role.0, + template_def.slug, + ); + continue; + }; + + // Sample trust within the authored range. If range is degenerate, use min. + let trust: i8 = if constraint.required_trust.min >= constraint.required_trust.max { + constraint.required_trust.min + } else { + rng.rng.random_range( + constraint.required_trust.min..=constraint.required_trust.max, + ) + }; + + // Append the relationship — generate_npc starts with empty relationship_targets + // so there are no pre-existing duplicates to guard against. + if let Some(mut rels) = world.get_mut::<Relationships>(from_entity) { + rels.entries.push(Relationship { + target_id: with_stable_id, + kind: constraint.kind.clone(), + trust_level: trust, + history: vec![], + }); + } + } + } + + // ----------------------------------------------------------------------- + // Phase 3: Record cross-template reference links in TemplateReferenceMap + // ----------------------------------------------------------------------- + world.init_resource::<TemplateReferenceMap>(); + for link in &template_def.cross_template_links { + let to_template_id = TemplateId::from_seed_and_slug(world_seed, &link.to_template_slug); + world + .resource_mut::<TemplateReferenceMap>() + .add(TemplateReference { + from_template: template_id, + to_template: to_template_id, + via_role: link.from_role.clone(), + relationship_metadata: link.relationship.clone(), + }); + } + + TemplateSpawnResult { + role_assignments, + entities, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1100,6 +1271,48 @@ mod tests { assert_eq!(edge.trust, 7); } + #[test] + fn spawn_npc_combat_trained_sets_skill_flag() { + // #91: combat_trained: true in YAML sets SkillSet.combat_trained = true. + // Known gap: CombatCapability is NOT yet attached for content-spawned NPCs + // (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural + // path (generate.rs) correctly attaches CombatCapability. This test documents + // current behavior so the gap is visible in CI. + let mut world = create_test_world(); + let mut profile = create_test_profile(); + profile.skills = Some(NpcSkills { + combat_trained: Some(true), + skills: Some({ + let mut m = std::collections::BTreeMap::new(); + m.insert("combat".to_string(), 7); + m + }), + }); + + let mut result = SpawnResult::default(); + spawn_npc(&mut world, &profile, &mut result); + + let entity = world + .resource::<EntityRegistry>() + .to_entity(&result.npc_ids["test-npc"]) + .unwrap(); + + // SkillSet.combat_trained is correctly set from YAML (#91 — done) + let skills = world.get::<npc::SkillSet>(entity).unwrap(); + assert!( + skills.combat_trained, + "SkillSet.combat_trained should be true when YAML sets combat_trained: true" + ); + + // Known gap: CombatCapability not yet attached in content-spawn path. + // The procedural path (generate.rs) does attach it — content path has TODO. + // Update this assertion when the TODO is resolved. + assert!( + world.get::<npc::CombatCapability>(entity).is_none(), + "CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)" + ); + } + #[test] fn resolve_relationships_skips_unknown_targets() { let mut world = create_test_world(); @@ -1129,4 +1342,224 @@ mod tests { .unwrap(); assert!(world.get::<npc::Relationships>(entity).is_none()); } + + // ----------------------------------------------------------------------- + // #166 — Template-to-instance mapping tests + // ----------------------------------------------------------------------- + + fn minimal_template_def_4_roles() -> crate::content::template::FullTemplateDef { + use crate::content::template::{ + ConflictType, CrossTemplateLinkSpec, FullTemplateDef, NpcAxis, PrivacyLevel, + RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TrafficPattern, TriangleDef, + TriangleId, TrustRange, + }; + use crate::npc::{RelationshipKind, Skill}; + + FullTemplateDef { + slug: "test-hub".to_string(), + display_name: "Test Hub".to_string(), + description: None, + roles: vec![ + RoleSchema { + role_id: RoleId::new("manager"), + required_traits: vec![], + skill_focus: vec![Skill::Persuasion, Skill::Observation], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("worker"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 1, max: 4 }, + }], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("worker"), + required_traits: vec![], + skill_focus: vec![Skill::Technical], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![Skill::Combat, Skill::Observation], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("contact"), + required_traits: vec![], + skill_focus: vec![Skill::Stealth, Skill::Persuasion], + relationship_constraints: vec![], + routine_template: vec![], + }, + ], + space: SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![], + privacy_level: PrivacyLevel::SemiPrivate, + traffic_pattern: TrafficPattern::Destination, + }, + triangles: vec![ + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("worker"), + RoleId::new("guard"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("contact"), + RoleId::new("guard"), + ], + conflict_type: ConflictType::AuthorityChallenge, + interest_axes: [NpcAxis::Relationships, NpcAxis::Tolerance, NpcAxis::Secret], + relationship_constraints: vec![], + }, + ], + dialogue_pools: vec![], + cross_template_links: vec![CrossTemplateLinkSpec { + from_role: RoleId::new("worker"), + to_template_slug: "bar".to_string(), + relationship: crate::npc::RelationshipKind::Colleague, + }], + } + } + + #[test] + fn spawn_template_npcs_fills_all_role_slots() { + use crate::content::template::{RoleId, TemplateId}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + assert_eq!(result.role_assignments.len(), 4, "all 4 role slots must be filled"); + assert_eq!(result.entities.len(), 4, "4 entities expected"); + assert!(result.role_assignments.contains_key(&RoleId::new("manager"))); + assert!(result.role_assignments.contains_key(&RoleId::new("worker"))); + assert!(result.role_assignments.contains_key(&RoleId::new("guard"))); + assert!(result.role_assignments.contains_key(&RoleId::new("contact"))); + } + + #[test] + fn spawn_template_npcs_sets_template_ownership() { + use crate::content::template::{RoleId, TemplateId, TemplateOwnership}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + let valid_roles = [ + RoleId::new("manager"), + RoleId::new("worker"), + RoleId::new("guard"), + RoleId::new("contact"), + ]; + for entity in &result.entities { + let ownership = world + .get::<TemplateOwnership>(*entity) + .expect("entity must have TemplateOwnership"); + assert_eq!(ownership.template_id, template_id, "template_id must match"); + assert!( + valid_roles.contains(&ownership.role_id), + "role_id {:?} not in expected roles", + ownership.role_id, + ); + } + } + + #[test] + fn spawn_template_npcs_records_cross_template_references() { + use crate::content::template::{TemplateId, TemplateReferenceMap}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + let ref_map = world.resource::<TemplateReferenceMap>(); + let outgoing = ref_map.outgoing(template_id); + assert_eq!(outgoing.len(), 1, "one cross-template link expected"); + assert_eq!(outgoing[0].from_template, template_id); + let expected_target = TemplateId::from_seed_and_slug(42, "bar"); + assert_eq!( + outgoing[0].to_template, expected_target, + "target template_id must match seed+slug derivation" + ); + } + + #[test] + fn spawn_template_npcs_wires_relationship_constraints() { + use crate::content::template::{RoleId, TemplateId}; + use crate::simulation::rng::SimRng; + + let mut world = create_test_world(); + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + let mut rng = SimRng::new(42); + + let result = spawn_template_npcs(&mut world, &template_def, template_id, 42, &mut rng); + + // The "manager" role has a RelationshipConstraint toward "worker" (trust 1–4). + let manager_stable_id = result.role_assignments[&RoleId::new("manager")]; + let worker_stable_id = result.role_assignments[&RoleId::new("worker")]; + let manager_entity = world + .resource::<EntityRegistry>() + .to_entity(&manager_stable_id) + .unwrap(); + + let rels = world.get::<npc::Relationships>(manager_entity).unwrap(); + let worker_rel = rels.entries.iter().find(|r| r.target_id == worker_stable_id); + assert!( + worker_rel.is_some(), + "manager must have a relationship toward worker (from RelationshipConstraint)" + ); + let trust = worker_rel.unwrap().trust_level; + assert!( + trust >= 1 && trust <= 4, + "trust {} not in authored range [1, 4]", + trust + ); + } + + #[test] + fn spawn_template_npcs_is_deterministic() { + use crate::content::template::TemplateId; + use crate::simulation::rng::SimRng; + + let template_def = minimal_template_def_4_roles(); + let template_id = TemplateId::from_seed_and_slug(42, "test-hub"); + + let mut world1 = create_test_world(); + let result1 = + spawn_template_npcs(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42)); + + let mut world2 = create_test_world(); + let result2 = + spawn_template_npcs(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42)); + + assert_eq!( + result1.role_assignments, result2.role_assignments, + "spawn_template_npcs must be deterministic (D-010)" + ); + } } diff --git a/server/src/content/template.rs b/server/src/content/template.rs new file mode 100644 index 000000000..95eab01fa --- /dev/null +++ b/server/src/content/template.rs @@ -0,0 +1,1739 @@ +//! Social site template schema types (#163, #164, #165, #106, #250). +//! +//! This module defines the Tier 2 template system — the foundational schema for +//! social sites (D-025). Templates describe the role structure, spatial layout, +//! ownership model, and triangle conflict patterns for a functional cluster of +//! 4–8 NPCs in a 15–40 visual tile space. +//! +//! ## Architecture +//! +//! Content-side types (YAML deserialization): `RoleSchema`, `SpaceSpec`, `TriangleDef` +//! ECS-side types (runtime components/resources): `TemplateOwnership`, `TemplateReferenceMap` +//! +//! The content types are authored in YAML at `server/data/templates/` and consumed +//! by the template instantiation pipeline. The ECS types are assigned at spawn time +//! and persisted across save/load and tier transitions. +//! +//! ## Escalation (#250) +//! +//! `tick_triangle_escalation` runs once per game-minute (D-031) and increments +//! tension on Simmering/Active triangles. When tension exceeds the lowest +//! `ToleranceThreshold` among the triangle's NPCs, the triangle transitions +//! from Simmering → Active and a `TriangleCrisisEvent` is emitted. +//! +//! ## Determinism (D-010) +//! +//! - `TemplateId` and `TriangleId` use FNV-1a hashing for deterministic generation +//! from seed + slug. Never use `std::hash::DefaultHasher` (non-deterministic). +//! - All collections use `BTreeMap` / `Vec` (no `HashMap`). + +use bevy_ecs::prelude::*; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::StableId; +use crate::knowledge::EntityRegistry; +use crate::npc::{PersonalityTrait, RelationshipKind, Skill, ToleranceThreshold}; +use crate::simulation::rng::SimRng; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; + +// =========================================================================== +// #163 — Role definition schema +// =========================================================================== + +/// Stable role identifier within a template. +/// +/// A string slug (e.g. "bartender", "dock-worker") that is stable across +/// save/load. Not a bevy `Entity` — serializes cleanly via serde. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct RoleId(pub String); + +impl RoleId { + pub fn new(id: &str) -> Self { + RoleId(id.to_string()) + } +} + +impl From<RoleId> for String { + fn from(id: RoleId) -> Self { + id.0 + } +} + +/// Trust range constraint for a relationship. +/// Both bounds are inclusive: the generated trust value must be in `[min, max]`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TrustRange { + pub min: i8, + pub max: i8, +} + +/// Constraint on a relationship that must exist within the same template. +/// +/// Example: the "bartender" role must have a `Colleague` relationship with the +/// "waitstaff" role at trust level 2–5. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelationshipConstraint { + /// The other role this relationship targets (must exist in the same template). + pub with_role: RoleId, + /// Required relationship kind. + pub kind: RelationshipKind, + /// Required trust range for the generated relationship. + pub required_trust: TrustRange, +} + +/// A routine entry template: phase → location name mapping. +/// +/// Phase is a string slug (e.g. "morning", "evening") resolved to `DayPhase` +/// during template instantiation. Location names are resolved to tile positions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateRoutineEntry { + pub phase: String, + pub location: String, + #[serde(default)] + pub activity: Option<String>, +} + +/// Tier 2 role schema — constraints fed into the NPC generator (D-024). +/// +/// Distinct from `RoleDefinition` in `npc/generate.rs`: `RoleSchema` is the +/// authored content specification; `RoleDefinition` is the runtime builder +/// derived from it during template instantiation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleSchema { + /// Unique role identifier within the template. + pub role_id: RoleId, + /// Personality traits that NPCs filling this role should have. + #[serde(default)] + pub required_traits: Vec<PersonalityTrait>, + /// Skills biased toward for this role. + #[serde(default)] + pub skill_focus: Vec<Skill>, + /// Relationship constraints with other roles in the same template. + #[serde(default)] + pub relationship_constraints: Vec<RelationshipConstraint>, + /// Routine template: phase → location name mappings. + #[serde(default)] + pub routine_template: Vec<TemplateRoutineEntry>, +} + +impl RoleSchema { + /// Validate this role schema. + /// + /// Checks: + /// - No self-referential relationship constraints (with_role != role_id). + /// - Trust range min <= max. + /// + /// Returns `Err` with a description of the first validation failure. + pub fn validate(&self) -> Result<(), String> { + for (i, constraint) in self.relationship_constraints.iter().enumerate() { + if constraint.with_role == self.role_id { + return Err(format!( + "relationship_constraints[{}]: self-referential constraint (with_role == role_id '{}')", + i, self.role_id.0 + )); + } + if constraint.required_trust.min > constraint.required_trust.max { + return Err(format!( + "relationship_constraints[{}]: trust min ({}) > max ({})", + i, constraint.required_trust.min, constraint.required_trust.max + )); + } + } + + Ok(()) + } +} + +/// Validate that a collection of `RoleSchema`s contains no duplicate `role_id`s. +/// +/// Returns `Err` naming the first duplicate found. +pub fn validate_role_schemas_no_duplicate_ids(schemas: &[RoleSchema]) -> Result<(), String> { + let mut seen = std::collections::BTreeSet::new(); + for schema in schemas { + if !seen.insert(&schema.role_id) { + return Err(format!("duplicate role_id: '{}'", schema.role_id.0)); + } + } + Ok(()) +} + +// =========================================================================== +// #164 — Spatial requirement specification +// =========================================================================== + +/// Named sub-area with a sightline coverage radius. +/// +/// Radius is in sim tiles (0.5m each per D-066). Example: `radius: 4` = 2m +/// clear sightline from the zone center. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SightlineZone { + pub name: String, + /// Coverage radius in sim tiles (0.5m each). 4 sim tiles = 2m. + pub radius: u32, +} + +/// Privacy level governing NPC disclosure behavior. +/// +/// NPCs are less likely to disclose secrets in `Public` spaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrivacyLevel { + Public, + SemiPrivate, + Private, +} + +/// Traffic pattern governing procedural NPC routine routing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrafficPattern { + /// High-traffic corridor — many NPCs route through. + Thoroughfare, + /// Destination point — NPCs travel TO this space, don't pass through. + Destination, + /// Limited access — only assigned NPCs enter. + Restricted, +} + +/// Spatial requirement specification for a template. +/// +/// Tile counts are in **sim tiles** (0.5m each per D-066). +/// A 15–40 visual tile space (per D-025) = 30–80 sim tiles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpaceSpec { + /// Minimum tile count in sim tiles (0.5m each). + pub tile_count_min: u32, + /// Maximum tile count in sim tiles (0.5m each). + pub tile_count_max: u32, + /// Named sightline zones within this space. + #[serde(default)] + pub sightline_zones: Vec<SightlineZone>, + /// Privacy level for NPC behavior modulation. + pub privacy_level: PrivacyLevel, + /// Traffic pattern for routine routing. + pub traffic_pattern: TrafficPattern, +} + +impl SpaceSpec { + /// Validate this space spec. + /// + /// Checks: + /// - tile_count_min <= tile_count_max. + /// - tile_count_min > 0. + /// + /// Returns `Err` with a description of the first validation failure. + pub fn validate(&self) -> Result<(), String> { + if self.tile_count_min == 0 { + return Err("tile_count_min must be > 0".to_string()); + } + if self.tile_count_min > self.tile_count_max { + return Err(format!( + "tile_count_min ({}) > tile_count_max ({})", + self.tile_count_min, self.tile_count_max + )); + } + + Ok(()) + } +} + +// =========================================================================== +// #165 — Single-ownership model +// =========================================================================== + +/// Deterministic template identifier. +/// +/// Generated from world seed + template slug via FNV-1a hash. Stable across +/// save/load — never derived from bevy `Entity` handles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct TemplateId(pub u64); + +impl TemplateId { + /// Compute a deterministic `TemplateId` from a world seed and template slug. + /// + /// Uses FNV-1a (64-bit) for determinism — `std::hash::DefaultHasher` is + /// prohibited by D-010 principle 4 (non-deterministic across Rust versions). + pub fn from_seed_and_slug(seed: u64, slug: &str) -> Self { + let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis, XOR'd with seed + for byte in slug.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV-1a prime + } + TemplateId(hash) + } +} + +/// ECS component: which template owns this NPC and which role it fills. +/// +/// Assigned at template instantiation, **never reassigned** (D-025 single-ownership). +/// Preserved across tier transitions and save/load. +#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateOwnership { + pub template_id: TemplateId, + pub role_id: RoleId, +} + +/// A cross-template reference link. +/// +/// Records that a role in one template has a relationship with a role in +/// another template. Preserved when templates are unloaded (tier eviction) +/// so the social web metadata survives even when NPCs aren't in Active tier. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateReference { + pub from_template: TemplateId, + pub to_template: TemplateId, + pub via_role: RoleId, + pub relationship_metadata: RelationshipKind, +} + +/// Resource: all cross-template reference links, indexed by source template. +/// +/// Uses `BTreeMap` for deterministic iteration (D-010). +/// Preserved across save/load — inserted into `SaveStateV1`. +#[derive(Resource, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateReferenceMap { + pub entries: BTreeMap<TemplateId, Vec<TemplateReference>>, +} + +impl TemplateReferenceMap { + /// Add a reference link. Appends to the entry list for `ref_link.from_template`. + pub fn add(&mut self, ref_link: TemplateReference) { + self.entries + .entry(ref_link.from_template) + .or_default() + .push(ref_link); + } + + /// Get all outgoing references from a template. + pub fn outgoing(&self, template_id: TemplateId) -> &[TemplateReference] { + self.entries + .get(&template_id) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Iterate over all references across all templates. + /// Iteration order is deterministic (BTreeMap key ordering). + pub fn all_references(&self) -> impl Iterator<Item = &TemplateReference> { + self.entries.values().flat_map(|v| v.iter()) + } +} + +// =========================================================================== +// #106 — Triangle definition schema +// =========================================================================== + +/// Deterministic triangle identifier. +/// +/// Generated from template seed + sorted role triple via FNV-1a hash. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct TriangleId(pub u64); + +impl TriangleId { + /// Compute a deterministic `TriangleId` from a seed and three role IDs. + /// + /// Roles are sorted before hashing to ensure the same triple always produces + /// the same ID regardless of input order. + pub fn from_seed_and_roles(seed: u64, roles: &[RoleId; 3]) -> Self { + let mut sorted: Vec<&str> = roles.iter().map(|r| r.0.as_str()).collect(); + sorted.sort(); + + let mut hash = seed ^ 0xcbf29ce484222325; // FNV-1a offset basis + for role_str in sorted { + for byte in role_str.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + // Separator to avoid "ab" + "c" == "a" + "bc" + hash ^= 0xFF; + hash = hash.wrapping_mul(0x100000001b3); + } + TriangleId(hash) + } +} + +impl From<TriangleId> for u64 { + fn from(id: TriangleId) -> Self { + id.0 + } +} + +/// Which NPC axis is in tension for a given role in a triangle. +/// +/// Maps to the D-024 10-axis model. Used to specify which axis diverges +/// for each of the three roles in a `TriangleDef`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum NpcAxis { + Want, + Secret, + Relationships, + Tolerance, + Routine, + InformationInventory, + Contentment, + PersonalityTraits, + TellSystem, + SkillSet, +} + +/// Conflict type classification per D-087 active fork patterns. +/// +/// Active forks use `ResourceCompetition`, `LoyaltyConflict`, `SecretExposure`, +/// or `AuthorityChallenge`. Passive tensions use `LatentTension`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConflictType { + ResourceCompetition, + LoyaltyConflict, + SecretExposure, + AuthorityChallenge, + LatentTension, +} + +/// Triangle definition — the atomic unit of social intrigue (D-024). +/// +/// Three roles, each with a conflicting NPC axis. Authored as part of a template +/// definition or as a standalone `triangles.yaml`. +/// +/// D-089: self-contained for v0.1. No cross-triangle cascade fields. +/// Cross-template triangles reference a `RoleId` from a different `TemplateId` +/// via the role itself, not a special triangle type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TriangleDef { + /// Deterministic triangle identifier. + pub triangle_id: TriangleId, + /// The three roles involved. Must all be distinct. + pub roles: [RoleId; 3], + /// Classification of the conflict. + pub conflict_type: ConflictType, + /// Which NPC axis is in tension for each of the three roles. + pub interest_axes: [NpcAxis; 3], + /// Additional relationship constraints specific to this triangle. + #[serde(default)] + pub relationship_constraints: Vec<RelationshipConstraint>, +} + +impl TriangleDef { + /// Validate this triangle definition. + /// + /// Checks: + /// - All three roles are distinct. + /// - Relationship constraint trust ranges are valid (min <= max). + /// + /// Returns `Err` with a description of the first validation failure. + pub fn validate(&self) -> Result<(), String> { + if self.roles[0] == self.roles[1] { + return Err(format!( + "roles[0] and roles[1] are identical: '{}'", + self.roles[0].0 + )); + } + if self.roles[0] == self.roles[2] { + return Err(format!( + "roles[0] and roles[2] are identical: '{}'", + self.roles[0].0 + )); + } + if self.roles[1] == self.roles[2] { + return Err(format!( + "roles[1] and roles[2] are identical: '{}'", + self.roles[1].0 + )); + } + + let role_set: std::collections::BTreeSet<&RoleId> = self.roles.iter().collect(); + for (i, constraint) in self.relationship_constraints.iter().enumerate() { + if constraint.required_trust.min > constraint.required_trust.max { + return Err(format!( + "relationship_constraints[{}]: trust min ({}) > max ({})", + i, constraint.required_trust.min, constraint.required_trust.max + )); + } + if !role_set.contains(&constraint.with_role) { + return Err(format!( + "relationship_constraints[{}]: with_role '{}' not in triangle roles", + i, constraint.with_role.0 + )); + } + } + + Ok(()) + } +} + +// =========================================================================== +// #159 — Full Tier 2 template document +// =========================================================================== + +/// Dialogue pool reference within a template (D-028). +/// +/// Refers to an existing authored dialogue pool by (location, roles). +/// The pool content lives in the campaign dialogue files; this reference +/// wires the pool to the social site for runtime line selection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TemplateDialoguePoolRef { + /// The location identifier the pool is authored under (e.g., "the-terminal"). + pub location: String, + /// Role slugs from this template that draw from the pool. + /// Empty = all roles may draw from this pool. + #[serde(default)] + pub roles: Vec<String>, +} + +/// Specification of a cross-template link authored in the template file (D-025). +/// +/// At instantiation time the engine resolves these into `TemplateReference` +/// entries in `TemplateReferenceMap`. The actual target template is identified +/// by slug, not a pre-computed `TemplateId`, because IDs are seed-dependent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossTemplateLinkSpec { + /// The role in THIS template that holds the cross-template relationship. + pub from_role: RoleId, + /// Slug of the external template being referenced. + pub to_template_slug: String, + /// Relationship kind for the `TemplateReference` link. + pub relationship: RelationshipKind, +} + +/// Full Tier 2 social site template definition (#159). +/// +/// The composite YAML document combining all sub-schemas into one canonical +/// template file. Authored in `server/data/templates/*.yaml` and loaded by +/// the template instantiation engine (#161). +/// +/// Per D-023 (three-tier content model) and D-025 (social site as atomic unit): +/// one file = one social site = 4–8 roles + spatial spec + 2+ triangles. +/// +/// **YAML authoring note:** `triangle_id` fields in authored `TriangleDef`s +/// should be set to 0 as a placeholder — the instantiation engine overwrites +/// them with `TriangleId::from_seed_and_roles(world_seed, &roles)` at runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FullTemplateDef { + /// Stable slug used to derive `TemplateId` via `TemplateId::from_seed_and_slug`. + pub slug: String, + /// Human-readable display name. + pub display_name: String, + /// Optional authoring description (not surfaced to players). + #[serde(default)] + pub description: Option<String>, + /// Role definitions (D-024 10-axis NPC constraints per role, 4–8 roles). + pub roles: Vec<RoleSchema>, + /// Spatial requirements (D-025: 30–80 sim tiles). + pub space: SpaceSpec, + /// Triangle definitions (D-024 minimum 2 per template, D-087 configuration). + pub triangles: Vec<TriangleDef>, + /// Dialogue pool references for runtime line selection (D-028). + #[serde(default)] + pub dialogue_pools: Vec<TemplateDialoguePoolRef>, + /// Cross-template link specs resolved to `TemplateReferenceMap` at instantiation. + #[serde(default)] + pub cross_template_links: Vec<CrossTemplateLinkSpec>, +} + +impl FullTemplateDef { + /// Validate the full template definition. + /// + /// Checks (in order): + /// 1. No duplicate `role_id`s in the roles list. + /// 2. Each role validates individually. + /// 3. Space spec validates. + /// 4. At least 2 triangles (D-024). + /// 5. Each triangle validates individually. + /// 6. All role references in triangles are defined in the roles list. + /// + /// Returns `Err` with the first failure description found. + pub fn validate(&self) -> Result<(), String> { + // 1 — no duplicate role IDs + validate_role_schemas_no_duplicate_ids(&self.roles)?; + + // 2 — each role validates + for role in &self.roles { + role.validate().map_err(|e| format!("role '{}': {}", role.role_id.0, e))?; + } + + // 3 — space spec + self.space.validate()?; + + // 4 — minimum 2 triangles + if self.triangles.len() < 2 { + return Err(format!( + "template '{}': fewer than 2 triangles ({}) — D-024 requires minimum 2", + self.slug, + self.triangles.len() + )); + } + + // 5 — each triangle validates + for tri in &self.triangles { + tri.validate() + .map_err(|e| format!("triangle {:?}: {}", tri.triangle_id, e))?; + } + + // 6 — triangle role references must exist in roles list + let role_ids: BTreeSet<&RoleId> = self.roles.iter().map(|r| &r.role_id).collect(); + for tri in &self.triangles { + for role_id in &tri.roles { + if !role_ids.contains(role_id) { + return Err(format!( + "triangle {:?}: role '{}' is not defined in template roles", + tri.triangle_id, role_id.0 + )); + } + } + } + + Ok(()) + } +} + +// =========================================================================== +// #107 — Intra-template triangle generation +// =========================================================================== + +/// Phase of a triangle's lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrianglePhase { + /// Not yet active — waiting for conditions. + Dormant, + /// Tension is building but hasn't reached a crisis. + Simmering, + /// Tension has exceeded a threshold — crisis in progress. + Active, + /// Triangle has been resolved (by player or system). D-089: no cascade. + Resolved, +} + +/// Runtime state of an instantiated triangle (#107). +/// +/// ECS component attached to a dedicated triangle entity (not on an NPC). +/// Tracks the current tension level and phase for a specific triangle instance. +#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TriangleState { + /// Which triangle definition this instance was generated from. + pub triangle_id: TriangleId, + /// NPC role → StableId assignments for this triangle instance. + /// Uses `BTreeMap` for deterministic iteration (D-010). + pub role_assignments: BTreeMap<RoleId, StableId>, + /// Current tension level (0–255). Starts at a seeded value. + pub tension: u8, + /// Current lifecycle phase. + pub phase: TrianglePhase, + /// Per-tick tension increment rate, seeded at world-gen time. + /// Stored here so escalation system doesn't need to recompute. + pub tension_rate: u8, + /// Which template owns this triangle. + pub template_id: TemplateId, +} + +/// Result of triangle generation for a single template. +#[derive(Debug)] +pub struct TriangleGenerationResult { + /// Successfully generated triangle states. + pub triangles: Vec<TriangleState>, + /// Warnings emitted during generation (e.g., fallback assignments). + pub warnings: Vec<String>, +} + +/// Generate triangle instances from triangle definitions for a template (#107). +/// +/// For each `TriangleDef`, assigns NPCs (by `StableId`) to the three roles. +/// NPCs are queried from the ECS world by their `TemplateOwnership` component. +/// +/// Minimum 2 triangles per template — emits an error log if fewer than 2 +/// `TriangleDef` entries are provided. +/// +/// **Fallback behavior:** If no NPC satisfies a strict role constraint, the +/// closest match is used and a warning is logged. Generation never panics. +/// +/// All randomness flows through `rng` for determinism (D-010). +/// +/// Takes `&mut World` (inherently single-threaded) because it spawns +/// `TriangleState` entities. Consistent with `spawn.rs` template instantiation. +pub fn generate_intra_template_triangles( + world: &mut World, + template_id: TemplateId, + defs: &[TriangleDef], + rng: &mut SimRng, +) -> TriangleGenerationResult { + let mut result = TriangleGenerationResult { + triangles: Vec::new(), + warnings: Vec::new(), + }; + + if defs.len() < 2 { + tracing::error!( + "Template {:?}: fewer than 2 TriangleDefs provided ({}). D-024 requires minimum 2.", + template_id, + defs.len() + ); + } + + // Collect all NPCs owned by this template: (RoleId, StableId) + let npc_roles: Vec<(RoleId, StableId)> = { + let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>(); + q.iter(world) + .filter(|(own, _)| own.template_id == template_id) + .map(|(own, sid)| (own.role_id.clone(), sid.0)) + .collect() + }; + + // Build a role → StableId lookup (BTreeMap for determinism) + let role_to_npc: BTreeMap<RoleId, StableId> = npc_roles.into_iter().collect(); + + for def in defs { + // Validate triangle definition before generating TriangleState (#109). + // Mirrors the cross-template path in generate_cross_template_triangles. + if let Err(e) = validate_triangle_def(def) { + result.warnings.push(format!( + "Triangle {:?}: skipped — validation failed: {}", + def.triangle_id, e + )); + continue; + } + + let mut role_assignments = BTreeMap::new(); + let mut assigned_npcs = BTreeSet::new(); + let mut assignment_ok = true; + + for role_id in &def.roles { + if let Some(&stable_id) = role_to_npc.get(role_id) { + if assigned_npcs.contains(&stable_id) { + // This NPC is already assigned to another role in this triangle. + // Fall through to fallback instead of duplicating. + } else { + role_assignments.insert(role_id.clone(), stable_id); + assigned_npcs.insert(stable_id); + continue; + } + } + + // Fallback: pick the first available NPC not already assigned to this triangle. + let fallback = role_to_npc + .values() + .find(|sid| !assigned_npcs.contains(sid)); + + if let Some(&fallback_sid) = fallback { + result.warnings.push(format!( + "Triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})", + def.triangle_id, role_id.0, fallback_sid.0 + )); + role_assignments.insert(role_id.clone(), fallback_sid); + assigned_npcs.insert(fallback_sid); + } else { + result.warnings.push(format!( + "Triangle {:?}: no NPC available for role '{}' — skipping triangle", + def.triangle_id, role_id.0 + )); + assignment_ok = false; + break; + } + } + + if !assignment_ok { + continue; + } + + // Seed initial tension and rate from RNG + let initial_tension: u8 = rng.rng.random_range(5_u8..=25); + let tension_rate: u8 = rng.rng.random_range(1_u8..=5); + + result.triangles.push(TriangleState { + triangle_id: def.triangle_id, + role_assignments, + tension: initial_tension, + phase: TrianglePhase::Simmering, + tension_rate, + template_id, + }); + } + + result +} + +// =========================================================================== +// #109 — Triangle validation +// =========================================================================== + +/// Errors returned when a `TriangleDef` fails instantiation-time validation. +/// +/// These checks run before role assignment to catch degenerate triangle +/// definitions that cannot produce meaningful drama. +/// +/// Spec: #109, D-024, D-087 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationError { + /// None of the three role interest_axes is `NpcAxis::Want`. + /// + /// A viable conflict requires at least one role whose primary tension is + /// their Want axis — without it there is no active driver of conflict. + ConflictViability { triangle_id: TriangleId }, + + /// `relationship_constraints` is empty — no authored relationship links + /// the three roles together. + /// + /// A coherent triangle requires at least one explicit relationship + /// constraint documenting how the roles are socially connected. + RelationshipCoherence { triangle_id: TriangleId }, + + /// Two or more roles share the same `interest_axes` value. + /// + /// Each role must have a distinct tension axis so their interests genuinely + /// diverge. Duplicate axes indicate the triangle is underspecified. + InterestDivergence { + triangle_id: TriangleId, + duplicate_axis: NpcAxis, + }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidationError::ConflictViability { triangle_id } => write!( + f, + "Triangle {:?}: conflict viability — no NpcAxis::Want among the three interest_axes", + triangle_id + ), + ValidationError::RelationshipCoherence { triangle_id } => write!( + f, + "Triangle {:?}: relationship coherence — relationship_constraints is empty", + triangle_id + ), + ValidationError::InterestDivergence { triangle_id, duplicate_axis } => write!( + f, + "Triangle {:?}: interest divergence — duplicate interest_axes value {:?}", + triangle_id, duplicate_axis + ), + } + } +} + +/// Validate a `TriangleDef` for instantiation quality (#109). +/// +/// Three checks: +/// 1. **Conflict viability** — at least one of the three `interest_axes` is +/// `NpcAxis::Want`, ensuring an active want-driven tension. +/// 2. **Relationship coherence** — `relationship_constraints` is non-empty, +/// documenting at least one social link among the three roles. +/// 3. **Interest divergence** — all three `interest_axes` are distinct, so +/// each role brings a genuinely different tension to the triangle. +/// +/// Returns `Ok(())` if all checks pass, or `Err(ValidationError)` on the +/// first failure (conflict viability is checked first, then coherence, then +/// divergence). +pub fn validate_triangle_def(def: &TriangleDef) -> Result<(), ValidationError> { + // 1. Conflict viability: at least one Want axis + if !def.interest_axes.iter().any(|a| *a == NpcAxis::Want) { + return Err(ValidationError::ConflictViability { + triangle_id: def.triangle_id, + }); + } + + // 2. Relationship coherence: at least one relationship constraint + if def.relationship_constraints.is_empty() { + return Err(ValidationError::RelationshipCoherence { + triangle_id: def.triangle_id, + }); + } + + // 3. Interest divergence: all three axes must be distinct + let [a0, a1, a2] = def.interest_axes; + if a0 == a1 { + return Err(ValidationError::InterestDivergence { + triangle_id: def.triangle_id, + duplicate_axis: a0, + }); + } + if a0 == a2 { + return Err(ValidationError::InterestDivergence { + triangle_id: def.triangle_id, + duplicate_axis: a0, + }); + } + if a1 == a2 { + return Err(ValidationError::InterestDivergence { + triangle_id: def.triangle_id, + duplicate_axis: a1, + }); + } + + Ok(()) +} + +// =========================================================================== +// #108 — Cross-template triangle generation +// =========================================================================== + +/// Generate triangle instances that span two social site templates (#108). +/// +/// Implements the 1 cross-template triangle required by D-024 ("2 per template +/// minimum, 1 cross-template"). Role assignments draw from NPCs owned by +/// *either* `template_a_id` or `template_b_id` — the combined pool is used +/// for role lookup. +/// +/// The generated `TriangleState` is owned by `template_a_id`. D-025 ownership +/// model: NPCs are owned by one template but can hold reference roles in +/// another; the cross-template triangle represents this social link. +/// +/// **Validation:** each `TriangleDef` is validated via `validate_triangle_def` +/// before role assignment. Invalid defs are skipped with a warning added to +/// the result. +/// +/// **Fallback behavior:** same as `generate_intra_template_triangles` — if no +/// NPC satisfies a role constraint, the closest available NPC is used and a +/// warning is logged. Generation never panics. +/// +/// All randomness flows through `rng` for determinism (D-010). +pub fn generate_cross_template_triangles( + world: &mut World, + template_a_id: TemplateId, + template_b_id: TemplateId, + defs: &[TriangleDef], + rng: &mut SimRng, +) -> TriangleGenerationResult { + let mut result = TriangleGenerationResult { + triangles: Vec::new(), + warnings: Vec::new(), + }; + + // Collect NPCs from both templates into a single role → StableId lookup. + // BTreeMap for determinism (D-010). If both templates define the same + // role slug, template_a wins (insertion order: a first, b second via + // entry().or_insert). + let role_to_npc: BTreeMap<RoleId, StableId> = { + let mut q = world.query::<(&TemplateOwnership, &StableEntityId)>(); + let mut map = BTreeMap::new(); + for (own, sid) in q.iter(world) { + if own.template_id == template_a_id || own.template_id == template_b_id { + map.entry(own.role_id.clone()).or_insert(sid.0); + } + } + map + }; + + for def in defs { + // Validate the def before attempting role assignment. + if let Err(e) = validate_triangle_def(def) { + result.warnings.push(format!( + "Cross-template triangle {:?}: skipped — validation failed: {}", + def.triangle_id, e + )); + continue; + } + + let mut role_assignments = BTreeMap::new(); + let mut assigned_npcs = BTreeSet::new(); + let mut assignment_ok = true; + + for role_id in &def.roles { + if let Some(&stable_id) = role_to_npc.get(role_id) { + if !assigned_npcs.contains(&stable_id) { + role_assignments.insert(role_id.clone(), stable_id); + assigned_npcs.insert(stable_id); + continue; + } + } + + // Fallback: first available NPC not already in this triangle. + let fallback = role_to_npc + .values() + .find(|sid| !assigned_npcs.contains(sid)); + + if let Some(&fallback_sid) = fallback { + result.warnings.push(format!( + "Cross-template triangle {:?}: no NPC for role '{}' — assigned fallback StableId({})", + def.triangle_id, role_id.0, fallback_sid.0 + )); + role_assignments.insert(role_id.clone(), fallback_sid); + assigned_npcs.insert(fallback_sid); + } else { + result.warnings.push(format!( + "Cross-template triangle {:?}: no NPC available for role '{}' — skipping", + def.triangle_id, role_id.0 + )); + assignment_ok = false; + break; + } + } + + if !assignment_ok { + continue; + } + + let initial_tension: u8 = rng.rng.random_range(5_u8..=25); + let tension_rate: u8 = rng.rng.random_range(1_u8..=5); + + result.triangles.push(TriangleState { + triangle_id: def.triangle_id, + role_assignments, + tension: initial_tension, + phase: TrianglePhase::Simmering, + tension_rate, + template_id: template_a_id, // cross-template triangle owned by template_a + }); + } + + result +} + +// =========================================================================== +// #250 — Triangle escalation system +// =========================================================================== + +/// Event emitted when a triangle transitions from Simmering to Active. +/// +/// Downstream systems (monologue, knowledge graph) can subscribe to this event +/// for narrative responses — wiring those subscribers is future work. +/// +/// Spec: #250, D-087 (v0.1 triangle config), D-089 (self-contained, no cascade) +#[derive(Debug, Clone)] +pub struct TriangleCrisisEvent { + /// Which triangle entered crisis. + pub triangle_id: TriangleId, + /// NPC role assignments at the time of crisis. + pub role_assignments: BTreeMap<RoleId, StableId>, + /// The NPC whose tolerance threshold was lowest (trigger). + pub trigger_npc: StableId, + /// Tick when the crisis was triggered. + pub tick: u64, +} + +/// Resource: queue of triangle crisis events (#250). +/// +/// Populated by `tick_triangle_escalation`. Drained by consumers +/// (monologue system, knowledge system — future work). +#[derive(Resource, Default)] +pub struct TriangleCrisisEventQueue { + pub events: Vec<TriangleCrisisEvent>, +} + +impl TriangleCrisisEventQueue { + pub fn push(&mut self, event: TriangleCrisisEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec<TriangleCrisisEvent> { + std::mem::take(&mut self.events) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Command to resolve an active triangle fork (#250, D-089). +/// +/// Resolution mechanism TBD — this is a stub. +/// D-089: resolution does not cascade to other triangles. +#[derive(Debug, Clone)] +pub struct ResolveTriangleCommand(pub TriangleId); + +/// Resource: queue of triangle resolution commands (#250). +/// +/// Populated by player actions (future) or scripted events. +/// Consumed by `apply_resolve_triangle` system. +#[derive(Resource, Default)] +pub struct ResolveTriangleQueue { + pub commands: Vec<ResolveTriangleCommand>, +} + +impl ResolveTriangleQueue { + pub fn push(&mut self, cmd: ResolveTriangleCommand) { + self.commands.push(cmd); + } + + pub fn drain(&mut self) -> Vec<ResolveTriangleCommand> { + std::mem::take(&mut self.commands) + } +} + +/// System: escalate triangle tension once per game-minute (#250). +/// +/// Runs every 10 ticks (per D-031). For each `TriangleState` on an +/// Active-tier entity in `Simmering` or `Active` phase: +/// - Increments `tension` by the triangle's seeded `tension_rate`. +/// - For Simmering: when tension exceeds the lowest `ToleranceThreshold` +/// among the triangle's three NPCs, transitions to `Active` and emits +/// a `TriangleCrisisEvent`. +/// - For Active: tension continues incrementing (narrative tracking). +/// +/// Dormant and Resolved triangles are not processed. +/// +/// Scoped to `ActiveSim` entities (D-026 tier boundary). +pub fn tick_triangle_escalation( + time: Res<SimulationTime>, + registry: Res<EntityRegistry>, + mut crisis_queue: ResMut<TriangleCrisisEventQueue>, + mut triangles: Query<&mut TriangleState, With<ActiveSim>>, + thresholds: Query<&ToleranceThreshold>, +) { + // Only process on game-minute boundaries (every 10 ticks, D-031) + if time.tick % TICKS_PER_GAME_MINUTE != 0 { + return; + } + + for mut state in triangles.iter_mut() { + match state.phase { + TrianglePhase::Simmering => { + state.tension = state.tension.saturating_add(state.tension_rate); + + // Find the lowest ToleranceThreshold among the triangle's NPCs. + // The NPC with the lowest threshold is the "weakest link" that + // triggers the crisis transition. + let mut min_threshold: Option<(i16, StableId)> = None; + for stable_id in state.role_assignments.values() { + if let Some(entity) = registry.to_entity(stable_id) { + if let Ok(tt) = thresholds.get(entity) { + match min_threshold { + None => min_threshold = Some((tt.threshold, *stable_id)), + Some((current_min, _)) if tt.threshold < current_min => { + min_threshold = Some((tt.threshold, *stable_id)); + } + _ => {} + } + } + } + } + + if let Some((threshold, trigger_npc)) = min_threshold { + if i16::from(state.tension) > threshold { + state.phase = TrianglePhase::Active; + crisis_queue.push(TriangleCrisisEvent { + triangle_id: state.triangle_id, + role_assignments: state.role_assignments.clone(), + trigger_npc, + tick: time.tick, + }); + tracing::info!( + "Triangle {:?}: Simmering → Active (tension={}, threshold={}, trigger={:?}) at tick {}", + state.triangle_id, + state.tension, + threshold, + trigger_npc, + time.tick + ); + } + } + } + TrianglePhase::Active => { + // Continue incrementing for narrative tracking + state.tension = state.tension.saturating_add(state.tension_rate); + } + // Dormant and Resolved: no action + TrianglePhase::Dormant | TrianglePhase::Resolved => {} + } + } +} + +/// System: apply triangle resolution commands (#250, D-089). +/// +/// Reads `ResolveTriangleQueue` and sets matching `TriangleState.phase` to +/// `Resolved`. D-089: resolution does not cascade to other triangles. +pub fn apply_resolve_triangle( + mut queue: ResMut<ResolveTriangleQueue>, + mut triangles: Query<(Entity, &mut TriangleState)>, +) { + let commands = queue.drain(); + if commands.is_empty() { + return; + } + + // Build index: O(N) scan once, then O(1) per resolve command. + // Avoids O(N*M) full scan when multiple resolves fire in one tick. + let id_to_entity: BTreeMap<TriangleId, Entity> = triangles + .iter() + .map(|(entity, state)| (state.triangle_id.clone(), entity)) + .collect(); + + for cmd in commands { + if let Some(&entity) = id_to_entity.get(&cmd.0) { + if let Ok((_, mut state)) = triangles.get_mut(entity) { + state.phase = TrianglePhase::Resolved; + tracing::info!( + "Triangle {:?}: resolved (D-089, no cascade)", + state.triangle_id + ); + } + } + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // #163 — RoleSchema tests + // (YAML roundtrip, validation, duplicates covered by integration tests + // in tests/template_schema.rs — only unique tests here) + // ----------------------------------------------------------------------- + + // ----------------------------------------------------------------------- + // #164 — SpaceSpec tests + // (YAML roundtrip, min>max covered by integration tests — + // zero_min and valid_passes are unique) + // ----------------------------------------------------------------------- + + #[test] + fn space_spec_validate_catches_min_gt_max() { + let spec = SpaceSpec { + tile_count_min: 100, + tile_count_max: 50, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Public, + traffic_pattern: TrafficPattern::Thoroughfare, + }; + + assert!(spec.validate().is_err()); + } + + #[test] + fn space_spec_validate_catches_zero_min() { + let spec = SpaceSpec { + tile_count_min: 0, + tile_count_max: 50, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Public, + traffic_pattern: TrafficPattern::Thoroughfare, + }; + + assert!(spec.validate().is_err()); + } + + #[test] + fn space_spec_valid_passes() { + let spec = SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![SightlineZone { + name: "main_area".into(), + radius: 6, + }], + privacy_level: PrivacyLevel::Private, + traffic_pattern: TrafficPattern::Restricted, + }; + + assert!(spec.validate().is_ok()); + } + + // ----------------------------------------------------------------------- + // #165 — Single-ownership model tests + // (TemplateId determinism covered by integration tests — + // serialization roundtrips are unique) + // ----------------------------------------------------------------------- + + #[test] + fn template_ownership_serialize_roundtrip() { + let ownership = TemplateOwnership { + template_id: TemplateId::from_seed_and_slug(42, "cantina"), + role_id: RoleId::new("bartender"), + }; + + let bytes = rmp_serde::to_vec_named(&ownership).expect("serialize"); + let recovered: TemplateOwnership = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(ownership, recovered); + } + + #[test] + fn template_reference_map_add_and_query() { + let mut map = TemplateReferenceMap::default(); + let t1 = TemplateId::from_seed_and_slug(1, "cantina"); + let t2 = TemplateId::from_seed_and_slug(1, "dock_office"); + + map.add(TemplateReference { + from_template: t1, + to_template: t2, + via_role: RoleId::new("informant"), + relationship_metadata: RelationshipKind::Colleague, + }); + + assert_eq!(map.outgoing(t1).len(), 1); + assert_eq!(map.outgoing(t2).len(), 0); + assert_eq!(map.outgoing(t1)[0].to_template, t2); + } + + #[test] + fn template_reference_map_serialize_roundtrip() { + let mut map = TemplateReferenceMap::default(); + let t1 = TemplateId(100); + let t2 = TemplateId(200); + + map.add(TemplateReference { + from_template: t1, + to_template: t2, + via_role: RoleId::new("courier"), + relationship_metadata: RelationshipKind::Friend, + }); + + let bytes = rmp_serde::to_vec_named(&map).expect("serialize"); + let recovered: TemplateReferenceMap = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(map, recovered); + } + + // ----------------------------------------------------------------------- + // #106 — Triangle definition schema tests + // (YAML roundtrip, validation, D-087 T1 covered by integration tests — + // order-independence and passive tension are unique) + // ----------------------------------------------------------------------- + + #[test] + fn triangle_id_deterministic_and_order_independent() { + let roles = [ + RoleId::new("smuggler"), + RoleId::new("detective"), + RoleId::new("informant"), + ]; + let id1 = TriangleId::from_seed_and_roles(42, &roles); + + let roles_reordered = [ + RoleId::new("detective"), + RoleId::new("informant"), + RoleId::new("smuggler"), + ]; + let id2 = TriangleId::from_seed_and_roles(42, &roles_reordered); + assert_eq!(id1, id2); + + let id3 = TriangleId::from_seed_and_roles(99, &roles); + assert_ne!(id1, id3); + } + + #[test] + fn d087_passive_tension_expressible() { + let def = TriangleDef { + triangle_id: TriangleId(999), + roles: [ + RoleId::new("worker_a"), + RoleId::new("worker_b"), + RoleId::new("supervisor"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![], + }; + + assert!(def.validate().is_ok()); + } + + // ----------------------------------------------------------------------- + // #107 — Intra-template triangle generation tests + // ----------------------------------------------------------------------- + + fn spawn_template_npc( + world: &mut World, + template_id: TemplateId, + role: &str, + stable_id: u64, + ) -> bevy_ecs::entity::Entity { + world + .spawn(( + crate::npc::Npc, + TemplateOwnership { + template_id, + role_id: RoleId::new(role), + }, + StableEntityId(StableId(stable_id)), + )) + .id() + } + + #[test] + fn generate_triangles_basic_4_npc_template() { + let mut world = bevy_ecs::world::World::new(); + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + let mut rng = SimRng::new(42); + + // Spawn 4 NPCs + spawn_template_npc(&mut world, tid, "bartender", 1); + spawn_template_npc(&mut world, tid, "waitstaff", 2); + spawn_template_npc(&mut world, tid, "bouncer", 3); + spawn_template_npc(&mut world, tid, "regular", 4); + + let defs = vec![ + TriangleDef { + triangle_id: TriangleId::from_seed_and_roles( + 42, + &[ + RoleId::new("bartender"), + RoleId::new("waitstaff"), + RoleId::new("bouncer"), + ], + ), + roles: [ + RoleId::new("bartender"), + RoleId::new("waitstaff"), + RoleId::new("bouncer"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("waitstaff"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }, + TriangleDef { + triangle_id: TriangleId::from_seed_and_roles( + 42, + &[ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("regular"), + ], + ), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("regular"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Tolerance, NpcAxis::Contentment], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 3 }, + }], + }, + ]; + + let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng); + + assert_eq!(result.triangles.len(), 2, "should generate 2 triangles"); + assert!(result.warnings.is_empty(), "no warnings expected: {:?}", result.warnings); + + // Verify role assignments + let t1 = &result.triangles[0]; + assert_eq!(t1.role_assignments.len(), 3); + assert_eq!(t1.role_assignments[&RoleId::new("bartender")], StableId(1)); + assert_eq!(t1.role_assignments[&RoleId::new("waitstaff")], StableId(2)); + assert_eq!(t1.role_assignments[&RoleId::new("bouncer")], StableId(3)); + assert_eq!(t1.phase, TrianglePhase::Simmering); + assert!(t1.tension >= 5 && t1.tension <= 25); + assert!(t1.tension_rate >= 1 && t1.tension_rate <= 5); + + let t2 = &result.triangles[1]; + assert_eq!(t2.role_assignments[&RoleId::new("regular")], StableId(4)); + } + + #[test] + fn generate_triangles_fallback_on_missing_role() { + let mut world = bevy_ecs::world::World::new(); + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + let mut rng = SimRng::new(42); + + // Only 2 NPCs but triangle needs 3 roles + spawn_template_npc(&mut world, tid, "bartender", 1); + spawn_template_npc(&mut world, tid, "bouncer", 2); + + let defs = vec![ + TriangleDef { + triangle_id: TriangleId(100), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("missing_role"), // no NPC has this role + ], + conflict_type: ConflictType::SecretExposure, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }, + TriangleDef { + triangle_id: TriangleId(101), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("also_missing"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Rival, + required_trust: TrustRange { min: -5, max: 0 }, + }], + }, + ]; + + let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng); + + // First triangle: bartender(1) + bouncer(2) assigned, missing_role gets fallback + // But all NPCs are already used, so it should skip + // Actually: bartender(1) assigned, bouncer(2) assigned, missing_role needs fallback + // from remaining NPCs not already in this triangle's assignments. + // Both 1 and 2 are taken, so no fallback available → skip. + // Wait, let me re-check the logic... the fallback finds any NPC not already assigned + // to THIS triangle's role_assignments. After bartender(1) and bouncer(2), + // the remaining NPCs that aren't in role_assignments are... none (only 2 NPCs total). + // So this triangle gets skipped. + + // Actually with only 2 NPCs, both are already assigned before we need a 3rd. + // The triangle should be skipped with a warning. + assert!(!result.warnings.is_empty(), "should have warnings about missing roles"); + } + + #[test] + fn generate_triangles_fewer_than_2_defs_logs_error() { + // This test just verifies it doesn't panic — the error is logged. + let mut world = bevy_ecs::world::World::new(); + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + let mut rng = SimRng::new(42); + + spawn_template_npc(&mut world, tid, "bartender", 1); + spawn_template_npc(&mut world, tid, "bouncer", 2); + spawn_template_npc(&mut world, tid, "regular", 3); + + let defs = vec![TriangleDef { + triangle_id: TriangleId(200), + roles: [ + RoleId::new("bartender"), + RoleId::new("bouncer"), + RoleId::new("regular"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("bouncer"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }]; + + // Should succeed with 1 triangle but log an error about < 2 + let result = generate_intra_template_triangles(&mut world, tid, &defs, &mut rng); + assert_eq!(result.triangles.len(), 1); + } + + #[test] + fn generate_triangles_deterministic() { + let tid = TemplateId::from_seed_and_slug(42, "test-site"); + + let defs = vec![ + TriangleDef { + triangle_id: TriangleId(300), + roles: [ + RoleId::new("a"), + RoleId::new("b"), + RoleId::new("c"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("b"), + kind: crate::npc::RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }, + TriangleDef { + triangle_id: TriangleId(301), + roles: [ + RoleId::new("a"), + RoleId::new("c"), + RoleId::new("d"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Want, NpcAxis::Contentment, NpcAxis::Tolerance], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("c"), + kind: crate::npc::RelationshipKind::Rival, + required_trust: TrustRange { min: -5, max: 0 }, + }], + }, + ]; + + // Run twice with same seed + let mut world1 = bevy_ecs::world::World::new(); + spawn_template_npc(&mut world1, tid, "a", 1); + spawn_template_npc(&mut world1, tid, "b", 2); + spawn_template_npc(&mut world1, tid, "c", 3); + spawn_template_npc(&mut world1, tid, "d", 4); + let mut rng1 = SimRng::new(99); + let result1 = generate_intra_template_triangles(&mut world1, tid, &defs, &mut rng1); + + let mut world2 = bevy_ecs::world::World::new(); + spawn_template_npc(&mut world2, tid, "a", 1); + spawn_template_npc(&mut world2, tid, "b", 2); + spawn_template_npc(&mut world2, tid, "c", 3); + spawn_template_npc(&mut world2, tid, "d", 4); + let mut rng2 = SimRng::new(99); + let result2 = generate_intra_template_triangles(&mut world2, tid, &defs, &mut rng2); + + assert_eq!(result1.triangles.len(), result2.triangles.len()); + for (t1, t2) in result1.triangles.iter().zip(result2.triangles.iter()) { + assert_eq!(t1.tension, t2.tension, "tension must be deterministic"); + assert_eq!(t1.tension_rate, t2.tension_rate, "tension_rate must be deterministic"); + assert_eq!(t1.role_assignments, t2.role_assignments); + } + } + + #[test] + fn triangle_state_serialize_roundtrip() { + let state = TriangleState { + triangle_id: TriangleId(42), + role_assignments: { + let mut m = BTreeMap::new(); + m.insert(RoleId::new("a"), StableId(1)); + m.insert(RoleId::new("b"), StableId(2)); + m.insert(RoleId::new("c"), StableId(3)); + m + }, + tension: 15, + phase: TrianglePhase::Simmering, + tension_rate: 3, + template_id: TemplateId(100), + }; + + let bytes = rmp_serde::to_vec_named(&state).expect("serialize"); + let recovered: TriangleState = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(state, recovered); + } + + // ----------------------------------------------------------------------- + // #250 — Triangle escalation system tests + // ----------------------------------------------------------------------- + + use crate::simulation::time::SimulationTime; + use bevy_ecs::schedule::Schedule; + + /// Helper: set up a world with all resources needed for escalation tests. + fn setup_escalation_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::<SimulationTime>(); + world.init_resource::<TriangleCrisisEventQueue>(); + world.init_resource::<EntityRegistry>(); + world + } + + // escalation_simmering_to_active, resolve, dormant_skip, resolved_skip, + // game-minute-only, active-sim-only, saturation, resolve-targeting — all + // covered by integration tests in tests/triangle_escalation.rs. + // Only active_triangle_continues_incrementing is unique here. + + #[test] + fn active_triangle_continues_incrementing() { + let mut world = setup_escalation_world(); + + let triangle = world + .spawn(( + TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 5, + template_id: TemplateId(1), + }, + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Run at tick 10 and 20 + world.resource_mut::<SimulationTime>().tick = 10; + schedule.run(&mut world); + world.resource_mut::<SimulationTime>().tick = 20; + schedule.run(&mut world); + + let state = world.get::<TriangleState>(triangle).unwrap(); + assert_eq!(state.tension, 60, "Active triangle should gain +5 per game-minute × 2"); + assert_eq!( + state.phase, + TrianglePhase::Active, + "Active should remain Active" + ); + + // No crisis event for already-Active triangles + let queue = world.resource::<TriangleCrisisEventQueue>(); + assert!( + queue.is_empty(), + "no crisis event for triangles already in Active phase" + ); + } + + #[test] + fn tension_saturates_at_255() { + let mut world = setup_escalation_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + let triangle = world + .spawn(( + TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 254, + phase: TrianglePhase::Active, + tension_rate: 5, + template_id: TemplateId(1), + }, + ActiveSim, + )) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + schedule.run(&mut world); + + let state = world.get::<TriangleState>(triangle).unwrap(); + assert_eq!(state.tension, 255, "tension should saturate at u8::MAX (255)"); + } + + #[test] + fn resolve_only_targets_matching_triangle() { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::<ResolveTriangleQueue>(); + + let target = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + let bystander = world + .spawn(TriangleState { + triangle_id: TriangleId(200), + role_assignments: BTreeMap::new(), + tension: 30, + phase: TrianglePhase::Simmering, + tension_rate: 2, + template_id: TemplateId(1), + }) + .id(); + + // Resolve only triangle 100 + world + .resource_mut::<ResolveTriangleQueue>() + .push(ResolveTriangleCommand(TriangleId(100))); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + assert_eq!( + world.get::<TriangleState>(target).unwrap().phase, + TrianglePhase::Resolved, + "targeted triangle should be Resolved" + ); + assert_eq!( + world.get::<TriangleState>(bystander).unwrap().phase, + TrianglePhase::Simmering, + "D-089: non-targeted triangle must not be affected (no cascade)" + ); + } +} diff --git a/server/src/knowledge/registry.rs b/server/src/knowledge/registry.rs index 915d308d2..7f67b32b1 100644 --- a/server/src/knowledge/registry.rs +++ b/server/src/knowledge/registry.rs @@ -83,6 +83,38 @@ impl EntityRegistry { self.next_id = target; } + /// Register an entity with a specific pre-existing StableId (used during save/load). + /// + /// Unlike `register`, this does NOT advance `next_id`. After bulk-registering + /// all restored entities, call `advance_past(max_stable_id)` so future `register()` + /// calls produce IDs that don't conflict with the restored set. + /// + /// No-op if the entity is already mapped to the same `stable_id`. + /// Panics in debug builds if `stable_id` is already mapped to a different entity. + pub fn register_existing(&mut self, entity: Entity, stable_id: StableId) { + if let Some(&existing) = self.by_stable_id.get(&stable_id) { + debug_assert_eq!( + existing, entity, + "register_existing: StableId {:?} already mapped to a different entity", + stable_id + ); + return; + } + self.by_stable_id.insert(stable_id, entity); + self.by_entity.insert(entity, stable_id); + } + + /// Advance `next_id` past `id` so future `register()` calls don't conflict. + /// + /// Unlike `reserve_up_to`, this never panics: if the counter is already past `id`, + /// this is a no-op. Use after `register_existing` bulk-load to position the counter. + pub fn advance_past(&mut self, id: u64) { + let target = id.saturating_add(1); + if target > self.next_id { + self.next_id = target; + } + } + /// Number of registered entities. pub fn len(&self) -> usize { self.by_entity.len() diff --git a/server/src/knowledge/types.rs b/server/src/knowledge/types.rs index 76bed1018..ef1e72e3d 100644 --- a/server/src/knowledge/types.rs +++ b/server/src/knowledge/types.rs @@ -18,6 +18,12 @@ use crate::simulation::movement::TilePosition; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct StableId(pub u64); +impl From<StableId> for u64 { + fn from(id: StableId) -> Self { + id.0 + } +} + /// Typed fact identifier for non-entity knowledge. /// Format: "category.topic" (e.g., "contraband.ring_exists"). /// Lexicographic ordering in BTreeMap provides deterministic iteration. diff --git a/server/src/main.rs b/server/src/main.rs index 8913c7b54..298a1426e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -11,7 +11,7 @@ use bevy_app::prelude::*; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use settled_reach_server::bridge::tcp::TcpBridge; -use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; +use settled_reach_server::bridge::{BridgePlugin, BridgeResource, HandshakeState, ServerRunning}; use settled_reach_server::simulation::SimulationPlugin; fn main() { @@ -121,7 +121,17 @@ fn main() { tracing::error!("Failed to accept: {}", e); std::process::exit(1); }); - tracing::info!("Client connected, initializing simulation"); + tracing::info!("Client connected, sending protocol handshake"); + + // Protocol handshake: first framed message on the wire (#555). + // Client reads this and validates protocol_version before sending any input. + use settled_reach_server::bridge::SimBridge; + bridge.send_handshake().unwrap_or_else(|e| { + tracing::error!("Failed to send handshake: {}", e); + std::process::exit(1); + }); + + tracing::info!("Handshake sent, initializing simulation"); // RNG seed: test-mode defaults to 42 for deterministic replay let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 }); @@ -138,6 +148,7 @@ fn main() { }); app.add_plugins(settled_reach_server::content::ContentPlugin); app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(HandshakeState::Complete); // Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0) app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed)); @@ -165,11 +176,43 @@ fn main() { // Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses // non-blocking reads, so without throttling this loop would spin. // Remaining frame budget is available for NPC AI and pathfinding. + // + // Panic supervision (#85): each tick is wrapped in catch_unwind. + // On panic, the server sends a structured SimError to the client + // before shutting down, rather than an abrupt disconnect. let target_frame_time = std::time::Duration::from_millis(50); loop { let frame_start = std::time::Instant::now(); - app.update(); + // Wrap app.update() in catch_unwind to handle system panics (#85). + // AssertUnwindSafe is required because App is not UnwindSafe. + let tick_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + app.update(); + })); + + match tick_result { + Ok(()) => {} + Err(panic_payload) => { + // Extract panic message for error reporting + let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_payload.downcast_ref::<String>() { + s.clone() + } else { + "unknown panic".to_string() + }; + + tracing::error!("Simulation panic caught: {}", panic_msg); + + // Attempt to send a final SimError snapshot to the client. + // Best-effort: if the bridge is unavailable, we just log and exit. + send_panic_error(&app, &panic_msg); + + tracing::error!("Server shutting down after panic"); + break; + } + } + if !app.world().resource::<ServerRunning>().0 { break; } @@ -189,6 +232,78 @@ fn main() { tracing::info!("Simulation server shutting down"); } +/// Best-effort: send a final SimError snapshot to the client on panic (#85). +/// +/// Builds a minimal ObserverSnapshot with the panic error and sends it +/// through the bridge. If the bridge is unavailable or sending fails, +/// the error is logged but not fatal (we're already crashing). +fn send_panic_error(app: &App, panic_msg: &str) { + use settled_reach_server::bridge::types::*; + use settled_reach_server::simulation::time::{DayPhase, TickRate}; + + let world = app.world(); + + // Try to read current tick from SimulationTime + let tick = world + .get_resource::<settled_reach_server::simulation::time::SimulationTime>() + .map(|t| t.tick) + .unwrap_or(0); + + let bridge = match world.get_resource::<BridgeResource>() { + Some(b) => b, + None => { + tracing::error!("Cannot send panic error: no BridgeResource"); + return; + } + }; + + // Build a minimal snapshot carrying the panic error + let snapshot = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Paused, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + blocked_entities: vec![], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![SimError { + kind: SimErrorKind::Panic, + message: format!("Simulation panic: {}", panic_msg), + tick, + }], + }; + + if let Err(e) = bridge.send_snapshot(&snapshot) { + tracing::error!("Failed to send panic error to client: {}", e); + } else { + tracing::info!("Sent panic SimError to client at tick {}", tick); + } +} + /// Print bevy_ecs schedule graph and exit. /// Invoked by --dump-schedule CLI flag (#346). /// diff --git a/server/src/npc/awareness.rs b/server/src/npc/awareness.rs new file mode 100644 index 000000000..1682e710e --- /dev/null +++ b/server/src/npc/awareness.rs @@ -0,0 +1,605 @@ +//! NPC player-awareness system (#244). +//! +//! Tracks how aware an NPC is of the player's attention. When the player +//! lingers in an NPC's field of view, the NPC's suspicion accumulates and +//! feeds stress into `ToleranceThreshold` (D-024 axis 4). +//! +//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115). +//! Separate from the follow mechanic (#241): follow tracks player-initiated +//! proximity, awareness tracks NPC-perceived attention. +//! +//! ## System ordering +//! +//! `detect_player_awareness` runs: +//! - after `vision::compute_npc_vision` (needs player_visible) +//! - before `tolerance::check_tolerance_threshold` (feeds stress) +//! +//! ## Suspicion lifecycle +//! +//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting +//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building +//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance +//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly +//! +//! All arithmetic is integer-only (D-010 determinism). + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::npc::vision::NpcVisionState; +use crate::npc::{Npc, ToleranceThreshold}; +use crate::simulation::time::SimulationTime; +use crate::simulation::tier::ActiveSim; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Consecutive ticks the player must remain in an NPC's LOS before suspicion +/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute). +pub const AWARENESS_NOTICE_TICKS: u64 = 30; + +/// Stress increment per tick applied to `ToleranceThreshold` once the NPC +/// notices sustained player attention. Lighter than follow stress (2/tick) +/// because watching is less intrusive than tailing. +pub const AWARENESS_STRESS_PER_TICK: i16 = 1; + +/// Ticks between suspicion decay checks when player is NOT in LOS. +/// 10 ticks = 1 game-minute (D-031). +pub const AWARENESS_DECAY_INTERVAL: u64 = 10; + +/// Suspicion decay amount per interval when player is not in LOS. +pub const AWARENESS_DECAY_AMOUNT: i16 = 1; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/// Tracks an NPC's awareness of sustained player attention (#244). +/// +/// Updated each tick by `detect_player_awareness` for Active-tier NPCs. +/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises. +/// +/// ## Fields +/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries +/// - `consecutive_los_ticks` — resets when player leaves LOS +/// - `suspicion_level` — 0–100, accumulates while player watches, decays when they leave +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct PlayerAwareness { + /// Whether the player is currently in this NPC's line of sight. + pub player_in_los: bool, + /// Consecutive ticks the player has been in this NPC's LOS. + pub consecutive_los_ticks: u64, + /// Accumulated suspicion level (0–100, integer for D-010). + pub suspicion_level: i16, +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// Detect sustained player attention and build NPC suspicion. +/// +/// For each Active-tier NPC with `PlayerAwareness`: +/// - Sync `player_in_los` from `NpcVisionState.player_visible` +/// - If player visible: increment `consecutive_los_ticks`; once past +/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply +/// stress to `ToleranceThreshold` +/// - If player NOT visible: reset `consecutive_los_ticks`, decay +/// `suspicion_level` periodically +pub fn detect_player_awareness( + time: Res<SimulationTime>, + mut npc_query: Query< + ( + &NpcVisionState, + &mut PlayerAwareness, + &mut ToleranceThreshold, + ), + (With<Npc>, With<ActiveSim>), + >, +) { + for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() { + awareness.player_in_los = vision.player_visible; + + if vision.player_visible { + awareness.consecutive_los_ticks += 1; + + // Once the NPC has noticed sustained player attention, build suspicion + if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS { + awareness.suspicion_level = + (awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100); + tolerance.current_stress = tolerance + .current_stress + .saturating_add(AWARENESS_STRESS_PER_TICK); + } + } else { + // Player left LOS — reset consecutive counter + awareness.consecutive_los_ticks = 0; + + // Decay suspicion slowly when player is not visible + if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 { + awareness.suspicion_level = + (awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0); + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::vision::NpcVisionState; + use crate::npc::{Npc, ToleranceThreshold}; + use crate::simulation::time::SimulationTime; + use crate::simulation::tier::ActiveSim; + use bevy_ecs::world::World; + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::<SimulationTime>(); + world + } + + fn run_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(detect_player_awareness); + schedule.run(world); + } + + fn default_tolerance() -> ToleranceThreshold { + ToleranceThreshold { + current_stress: 0, + threshold: 80, + } + } + + fn vision_with_player(visible: bool) -> NpcVisionState { + NpcVisionState { + player_visible: visible, + ..Default::default() + } + } + + // ----------------------------------------------------------------------- + // Basic LOS tracking + // ----------------------------------------------------------------------- + + #[test] + fn player_in_los_increments_consecutive_ticks() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness::default(), + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert!(awareness.player_in_los); + assert_eq!(awareness.consecutive_los_ticks, 1); + } + + #[test] + fn player_leaving_los_resets_consecutive_ticks() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(false), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: 20, + suspicion_level: 5, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert!(!awareness.player_in_los); + assert_eq!(awareness.consecutive_los_ticks, 0); + } + + #[test] + fn player_in_los_syncs_from_vision_state() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: false, // was false + consecutive_los_ticks: 0, + suspicion_level: 0, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert!(awareness.player_in_los, "should sync from NpcVisionState"); + } + + // ----------------------------------------------------------------------- + // Suspicion threshold — no stress before notice ticks + // ----------------------------------------------------------------------- + + #[test] + fn no_stress_before_notice_threshold() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold + suspicion_level: 0, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.suspicion_level, 0, + "suspicion should not build before notice threshold" + ); + let tolerance = world.get::<ToleranceThreshold>(npc).unwrap(); + assert_eq!( + tolerance.current_stress, 0, + "stress should not increase before notice threshold" + ); + } + + #[test] + fn stress_starts_at_notice_threshold() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold + suspicion_level: 0, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS, + "consecutive ticks should reach threshold" + ); + assert_eq!( + awareness.suspicion_level, AWARENESS_STRESS_PER_TICK, + "suspicion should increase at threshold" + ); + let tolerance = world.get::<ToleranceThreshold>(npc).unwrap(); + assert_eq!( + tolerance.current_stress, AWARENESS_STRESS_PER_TICK, + "stress should increase at threshold" + ); + } + + // ----------------------------------------------------------------------- + // Suspicion accumulation + // ----------------------------------------------------------------------- + + #[test] + fn suspicion_accumulates_past_threshold() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5, + suspicion_level: 10, + }, + ToleranceThreshold { + current_stress: 20, + threshold: 80, + }, + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.suspicion_level, + 10 + AWARENESS_STRESS_PER_TICK, + "suspicion should accumulate" + ); + let tolerance = world.get::<ToleranceThreshold>(npc).unwrap(); + assert_eq!( + tolerance.current_stress, + 20 + AWARENESS_STRESS_PER_TICK, + "stress should accumulate" + ); + } + + #[test] + fn suspicion_caps_at_100() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100, + suspicion_level: 100, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100"); + } + + #[test] + fn tolerance_stress_saturates() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: AWARENESS_NOTICE_TICKS, + suspicion_level: 50, + }, + ToleranceThreshold { + current_stress: i16::MAX - 1, + threshold: i16::MAX, + }, + )) + .id(); + + run_system(&mut world); + + let tolerance = world.get::<ToleranceThreshold>(npc).unwrap(); + assert_eq!( + tolerance.current_stress, + i16::MAX, + "stress should saturate, not overflow" + ); + } + + // ----------------------------------------------------------------------- + // Suspicion decay + // ----------------------------------------------------------------------- + + #[test] + fn suspicion_decays_when_player_not_in_los_on_interval() { + let mut world = setup_world(); + // Set tick to a decay interval boundary + world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(false), + PlayerAwareness { + player_in_los: false, + consecutive_los_ticks: 0, + suspicion_level: 10, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.suspicion_level, + 10 - AWARENESS_DECAY_AMOUNT, + "suspicion should decay on interval tick" + ); + } + + #[test] + fn suspicion_does_not_decay_off_interval() { + let mut world = setup_world(); + // Set tick to a non-interval boundary + world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(false), + PlayerAwareness { + player_in_los: false, + consecutive_los_ticks: 0, + suspicion_level: 10, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.suspicion_level, 10, + "suspicion should not decay on non-interval tick" + ); + } + + #[test] + fn suspicion_does_not_go_below_zero() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(false), + PlayerAwareness { + player_in_los: false, + consecutive_los_ticks: 0, + suspicion_level: 0, + }, + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.suspicion_level, 0, + "suspicion should not go below zero" + ); + } + + // ----------------------------------------------------------------------- + // Background NPC not processed + // ----------------------------------------------------------------------- + + #[test] + fn background_npc_not_processed() { + let mut world = setup_world(); + + let npc = world + .spawn(( + Npc, + crate::simulation::tier::BackgroundSim, + vision_with_player(true), + PlayerAwareness::default(), + default_tolerance(), + )) + .id(); + + run_system(&mut world); + + let awareness = world.get::<PlayerAwareness>(npc).unwrap(); + assert_eq!( + awareness.consecutive_los_ticks, 0, + "background NPC should not have awareness processed" + ); + } + + // ----------------------------------------------------------------------- + // No panic with missing components + // ----------------------------------------------------------------------- + + #[test] + fn npc_without_awareness_no_panic() { + let mut world = setup_world(); + + // NPC with vision but no PlayerAwareness — system should skip + world.spawn(( + Npc, + ActiveSim, + vision_with_player(true), + default_tolerance(), + )); + + run_system(&mut world); // should not panic + } + + // ----------------------------------------------------------------------- + // Integration: follow + awareness stress stacking + // ----------------------------------------------------------------------- + + #[test] + fn awareness_stress_stacks_with_existing_stress() { + let mut world = setup_world(); + + // NPC already has stress from other sources (e.g., follow mechanic) + let npc = world + .spawn(( + Npc, + ActiveSim, + vision_with_player(true), + PlayerAwareness { + player_in_los: true, + consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold + suspicion_level: 5, + }, + ToleranceThreshold { + current_stress: 30, // pre-existing stress + threshold: 80, + }, + )) + .id(); + + run_system(&mut world); + + let tolerance = world.get::<ToleranceThreshold>(npc).unwrap(); + assert_eq!( + tolerance.current_stress, + 30 + AWARENESS_STRESS_PER_TICK, + "awareness stress should stack with existing stress" + ); + } + + // ----------------------------------------------------------------------- + // Constant value assertions (#244 spec compliance) + // ----------------------------------------------------------------------- + + #[test] + fn awareness_constants_have_expected_values() { + assert_eq!( + AWARENESS_NOTICE_TICKS, 30, + "#244: NPC notices sustained attention after 30 ticks (3 game-minutes)" + ); + assert_eq!( + AWARENESS_STRESS_PER_TICK, 1, + "#244: stress per tick lighter than follow stress (D-010 integer)" + ); + assert_eq!( + AWARENESS_DECAY_INTERVAL, 10, + "#244: suspicion decays every 10 ticks (1 game-minute, D-031)" + ); + assert_eq!( + AWARENESS_DECAY_AMOUNT, 1, + "#244: suspicion decays by 1 per interval" + ); + } +} diff --git a/server/src/npc/background.rs b/server/src/npc/background.rs new file mode 100644 index 000000000..366378442 --- /dev/null +++ b/server/src/npc/background.rs @@ -0,0 +1,730 @@ +//! Background tier state machines (#95, D-026). +//! +//! Four lightweight state machines for Background-tier NPCs, firing once per +//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10). +//! +//! ## State machines +//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding) +//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag) +//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline) +//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level` +//! +//! ## Design constraints (D-026) +//! - No pathfinding, LOS, or dialogue — those are Active-tier only. +//! - All arithmetic is integer-only (D-010 determinism requirement). +//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion). + +use bevy_ecs::prelude::*; + +use crate::npc::{ + Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold, +}; +use crate::npc::mood::{MoodState, NpcMood}; +use crate::npc::routine::ActivityState; +use crate::simulation::tier::BackgroundSim; +use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; + +// --------------------------------------------------------------------------- +// Mood derivation (background-tier) +// --------------------------------------------------------------------------- + +/// Derive simplified mood for a Background-tier NPC. +/// +/// No phase or warm-flag considerations — background NPCs have no active +/// interactions. Priority order: +/// 1. Hostile — stress ≥ threshold +/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010) +/// 3. Content — stress < 20 +/// 4. Neutral — otherwise +pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood { + // 1. Hostile: at or above threshold + if current_stress >= threshold { + return NpcMood::Hostile; + } + + // 2. Anxious: 60% of threshold reached + // Guard: skip if threshold == 0 (entity already Hostile from rule 1). + if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 { + return NpcMood::Anxious; + } + + // 3. Content: low stress + if current_stress < 20 { + return NpcMood::Content; + } + + // 4. Neutral: default + NpcMood::Neutral +} + +// --------------------------------------------------------------------------- +// Job performance drift +// --------------------------------------------------------------------------- + +/// Drift job performance score one point per game-minute based on contentment. +/// +/// - contentment > 0 → score + 1 (clamped at 100) +/// - contentment < 0 → score - 1 (clamped at 0) +/// - contentment = 0 → unchanged +pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 { + match contentment_level.cmp(&0) { + std::cmp::Ordering::Greater => (score + 1).min(100), + std::cmp::Ordering::Less => (score - 1).max(0), + std::cmp::Ordering::Equal => score, + } +} + +// --------------------------------------------------------------------------- +// System: background_tick +// --------------------------------------------------------------------------- + +/// System: run all four background-tier state machines once per game-minute. +/// +/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to +/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated +/// per-tick systems. +/// +/// Machine execution order per NPC: +/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase` +/// 2. Mood — update `MoodState` from stress/threshold (simplified) +/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0 +/// 4. Job — drift `JobPerformance.score` from `Contentment.level` +/// +/// Commands for `ActivityState` are deferred (applied after system runs). +/// Mutable component mutations happen immediately within the iteration. +pub fn background_tick( + time: Res<SimulationTime>, + mut commands: Commands, + mut query: Query< + ( + Entity, + Option<&ActivityState>, + &mut MoodState, + Option<&mut Relationships>, + Option<&ToleranceThreshold>, + Option<&DailyRoutine>, + Option<&Contentment>, + Option<&mut JobPerformance>, + ), + (With<Npc>, With<BackgroundSim>), + >, +) { + // Fire once per game-minute (D-031: 10 ticks/minute) + if time.tick % TICKS_PER_GAME_MINUTE != 0 { + return; + } + + let phase = time.day_phase(); + let tick = time.tick; + + for ( + entity, + activity_opt, + mut mood_state, + rels_opt, + tolerance_opt, + routine_opt, + contentment_opt, + job_opt, + ) in query.iter_mut() + { + // --- 1. Schedule: sync ActivityState to current DayPhase --- + // Background NPCs don't pathfind — we directly declare the activity. + if let Some(routine) = routine_opt { + if let Some(entry) = routine.entry_for_phase(phase) { + let needs_update = match activity_opt { + Some(a) => a.phase != phase || a.activity != entry.activity, + None => true, + }; + if needs_update { + commands.entity(entity).insert(ActivityState { + activity: entry.activity.clone(), + phase, + started_tick: tick, + }); + } + } else if activity_opt.is_some() { + // No routine entry for this phase — clear stale activity + commands.entity(entity).remove::<ActivityState>(); + } + } + + // --- 2. Mood: simplified stress-based derivation --- + let (stress, threshold) = tolerance_opt + .map(|t| (t.current_stress, t.threshold)) + .unwrap_or((0, 50)); // Default: no stress, moderate threshold + let new_mood = derive_background_mood(stress, threshold); + if mood_state.mood != new_mood { + mood_state.mood = new_mood; + mood_state.changed_tick = tick; + } + + // --- 3. Relationships: trust drift toward 0 (baseline) --- + if let Some(mut rels) = rels_opt { + for rel in &mut rels.entries { + if rel.trust_level > 0 { + rel.trust_level -= 1; + } else if rel.trust_level < 0 { + rel.trust_level += 1; + } + } + } + + // --- 4. Job: performance drift from contentment --- + if let Some(mut job) = job_opt { + let contentment = contentment_opt.map(|c| c.level).unwrap_or(0); + job.score = drift_job_performance(job.score, contentment); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::{ + Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind, + Relationships, RoutineEntry, ToleranceThreshold, + }; + use crate::npc::mood::{MoodState, NpcMood}; + use crate::npc::routine::ActivityState; + use crate::simulation::movement::TilePosition; + use crate::simulation::tier::{ActiveSim, BackgroundSim}; + use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE}; + use crate::knowledge::types::StableId; + use bevy_ecs::world::World; + + // --- derive_background_mood --- + + #[test] + fn background_mood_hostile_at_threshold() { + assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile); + } + + #[test] + fn background_mood_hostile_above_threshold() { + assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile); + } + + #[test] + fn background_mood_anxious_at_60_percent() { + // 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60) + assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious); + } + + #[test] + fn background_mood_anxious_boundary_below_threshold() { + // threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000) + assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious); + } + + #[test] + fn background_mood_content_low_stress() { + // stress=10 < 20 → Content (not hostile, not anxious) + assert_eq!(derive_background_mood(10, 50), NpcMood::Content); + } + + #[test] + fn background_mood_neutral_moderate_stress() { + // stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000), + // not content (25 >= 20) → Neutral + assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral); + } + + #[test] + fn background_mood_zero_threshold_is_hostile() { + // stress=0 >= threshold=0 → Hostile + assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile); + } + + #[test] + fn background_mood_zero_stress_moderate_threshold_is_content() { + // stress=0 < 20 → Content + assert_eq!(derive_background_mood(0, 50), NpcMood::Content); + } + + // --- drift_job_performance --- + + #[test] + fn job_drift_up_when_positive_contentment() { + assert_eq!(drift_job_performance(50, 10), 51); + } + + #[test] + fn job_drift_down_when_negative_contentment() { + assert_eq!(drift_job_performance(50, -10), 49); + } + + #[test] + fn job_drift_unchanged_at_zero_contentment() { + assert_eq!(drift_job_performance(50, 0), 50); + } + + #[test] + fn job_drift_clamps_at_100() { + assert_eq!(drift_job_performance(100, 5), 100); + } + + #[test] + fn job_drift_clamps_at_0() { + assert_eq!(drift_job_performance(0, -5), 0); + } + + // --- background_tick system integration tests --- + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::<SimulationTime>(); + world + } + + fn run_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(background_tick); + schedule.run(world); + } + + fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine { + DailyRoutine { + entries: vec![RoutineEntry { + phase, + location: TilePosition::new(5, 5, 0), + activity: activity.to_string(), + }], + description: "Test routine".into(), + } + } + + // --- Tick gating --- + + #[test] + fn does_not_fire_on_non_minute_tick() { + let mut world = setup_world(); + // Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE + world.resource_mut::<SimulationTime>().tick = 5; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + ToleranceThreshold { current_stress: 60, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + // Mood should NOT have updated (Hostile would fire if it ran) + let mood = world.get::<MoodState>(npc).unwrap(); + assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5"); + } + + #[test] + fn fires_at_tick_zero() { + let mut world = setup_world(); + // tick=0 is 0 % 10 == 0, so it fires + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + ToleranceThreshold { current_stress: 60, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + let mood = world.get::<MoodState>(npc).unwrap(); + assert_eq!(mood.mood, NpcMood::Hostile); + } + + #[test] + fn fires_at_tick_multiple_of_ticks_per_game_minute() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + ToleranceThreshold { current_stress: 60, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + let mood = world.get::<MoodState>(npc).unwrap(); + assert_eq!(mood.mood, NpcMood::Hostile); + } + + // --- Active-tier NPCs not processed --- + + #[test] + fn active_npcs_not_processed() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + // ActiveSim NPC — must NOT be processed by background_tick + let npc = world + .spawn(( + Npc, + ActiveSim, + MoodState { + mood: NpcMood::Warm, + changed_tick: 0, + }, + ToleranceThreshold { current_stress: 60, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + let mood = world.get::<MoodState>(npc).unwrap(); + assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick"); + } + + // --- Mood state machine --- + + #[test] + fn mood_hostile_when_stress_at_threshold() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + ToleranceThreshold { current_stress: 50, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile); + } + + #[test] + fn mood_content_when_no_tolerance() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + // No ToleranceThreshold → defaults (0, 50) → Content (0 < 20) + let npc = world + .spawn((Npc, BackgroundSim, MoodState::default())) + .id(); + + run_system(&mut world); + + assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content); + } + + #[test] + fn mood_records_changed_tick() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState { mood: NpcMood::Warm, changed_tick: 0 }, + ToleranceThreshold { current_stress: 55, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + let mood = world.get::<MoodState>(npc).unwrap(); + assert_eq!(mood.mood, NpcMood::Hostile); + assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3); + } + + #[test] + fn mood_unchanged_tick_not_updated() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + // Already Content, will derive Content → no change to changed_tick + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState { mood: NpcMood::Content, changed_tick: 42 }, + )) + .id(); + + run_system(&mut world); + + let mood = world.get::<MoodState>(npc).unwrap(); + assert_eq!(mood.mood, NpcMood::Content); + assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged"); + } + + // --- Schedule state machine --- + + #[test] + fn schedule_sets_activity_state_for_current_phase() { + let mut world = setup_world(); + // tick=0 → DayPhase::Morning + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + make_routine(DayPhase::Morning, "Work"), + )) + .id(); + + run_system(&mut world); + + let activity = world.get::<ActivityState>(npc).unwrap(); + assert_eq!(activity.activity, "Work"); + assert_eq!(activity.phase, DayPhase::Morning); + } + + #[test] + fn schedule_no_activity_when_no_routine_entry_for_phase() { + let mut world = setup_world(); + // tick=0 → Morning, but routine only has Afternoon + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + make_routine(DayPhase::Afternoon, "Meeting"), + )) + .id(); + + run_system(&mut world); + + // No ActivityState should be inserted (Morning has no entry) + assert!(world.get::<ActivityState>(npc).is_none()); + } + + #[test] + fn schedule_preserves_correct_activity_state() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; // Morning + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + make_routine(DayPhase::Morning, "Work"), + // Already has correct ActivityState — should not be re-inserted + ActivityState { + activity: "Work".into(), + phase: DayPhase::Morning, + started_tick: 0, + }, + )) + .id(); + + run_system(&mut world); + + let activity = world.get::<ActivityState>(npc).unwrap(); + assert_eq!(activity.activity, "Work"); + } + + // --- Relationships state machine --- + + #[test] + fn relationships_positive_trust_drifts_toward_zero() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + Relationships { + entries: vec![Relationship { + target_id: StableId(1), + kind: RelationshipKind::Friend, + trust_level: 5, + history: vec![], + }], + }, + )) + .id(); + + run_system(&mut world); + + let rels = world.get::<Relationships>(npc).unwrap(); + assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1"); + } + + #[test] + fn relationships_negative_trust_drifts_toward_zero() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + Relationships { + entries: vec![Relationship { + target_id: StableId(2), + kind: RelationshipKind::Rival, + trust_level: -4, + history: vec![], + }], + }, + )) + .id(); + + run_system(&mut world); + + let rels = world.get::<Relationships>(npc).unwrap(); + assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1"); + } + + #[test] + fn relationships_zero_trust_stays_zero() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + Relationships { + entries: vec![Relationship { + target_id: StableId(3), + kind: RelationshipKind::Colleague, + trust_level: 0, + history: vec![], + }], + }, + )) + .id(); + + run_system(&mut world); + + let rels = world.get::<Relationships>(npc).unwrap(); + assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged"); + } + + // --- Job state machine --- + + #[test] + fn job_performance_rises_with_positive_contentment() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + JobPerformance { score: 60 }, + Contentment { level: 20 }, + )) + .id(); + + run_system(&mut world); + + let job = world.get::<JobPerformance>(npc).unwrap(); + assert_eq!(job.score, 61); + } + + #[test] + fn job_performance_falls_with_negative_contentment() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + JobPerformance { score: 60 }, + Contentment { level: -15 }, + )) + .id(); + + run_system(&mut world); + + let job = world.get::<JobPerformance>(npc).unwrap(); + assert_eq!(job.score, 59); + } + + #[test] + fn job_performance_unchanged_at_zero_contentment() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let npc = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + JobPerformance { score: 50 }, + Contentment { level: 0 }, + )) + .id(); + + run_system(&mut world); + + let job = world.get::<JobPerformance>(npc).unwrap(); + assert_eq!(job.score, 50); + } + + #[test] + fn job_performance_without_component_no_panic() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + // No JobPerformance — system must not panic + let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id(); + + run_system(&mut world); // must not panic + } + + // --- Multiple NPCs independent --- + + #[test] + fn multiple_background_npcs_processed_independently() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 0; + + let calm = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + ToleranceThreshold { current_stress: 5, threshold: 50 }, + )) + .id(); + + let hostile = world + .spawn(( + Npc, + BackgroundSim, + MoodState::default(), + ToleranceThreshold { current_stress: 55, threshold: 50 }, + )) + .id(); + + run_system(&mut world); + + assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content); + assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile); + } +} diff --git a/server/src/npc/generate.rs b/server/src/npc/generate.rs index 395131694..3fa55a1f8 100644 --- a/server/src/npc/generate.rs +++ b/server/src/npc/generate.rs @@ -31,12 +31,15 @@ use bevy_ecs::prelude::*; use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId}; use crate::npc::{ - CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact, - Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships, - RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger, - ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS, + CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance, + KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, + Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, + TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS, }; +use crate::knowledge::graph::KnowledgeGraph; use crate::npc::mood::MoodState; +use crate::npc::awareness::PlayerAwareness; +use crate::npc::vision::{NpcMemory, NpcVisionState}; use crate::simulation::movement::TilePosition; use crate::simulation::rng::SimRng; use crate::simulation::tier::ActiveSim; @@ -266,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) -> DailyRoutine { entries, - description: format!("Routine schedule"), + description: "Routine schedule".to_string(), } } @@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng) tells, skills, MoodState::default(), + JobPerformance::default(), + )); + + // Vision + knowledge + awareness components (#115, #244, D-041). + // Separate insert to stay within tuple bundle element limit. + entity_builder.insert(( + KnowledgeGraph::new(), + NpcVisionState::default(), + NpcMemory::default(), + PlayerAwareness::default(), )); if let Some(cap) = combat_opt { @@ -876,6 +889,69 @@ mod tests { } } + // ----------------------------------------------------------------------- + // Combat capability — positive case (#91, D-024) + // ----------------------------------------------------------------------- + + #[test] + fn combat_role_probabilistically_produces_combat_capability() { + // #91 positive-case: at least one seed must produce CombatCapability for + // a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range), + // so 30 seeds is overwhelmingly likely to produce at least one match. + let role = minimal_role(); // combat_enabled = true + let mut any_combat = false; + for seed in 0..30_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + if world.get::<CombatCapability>(entity).is_some() { + any_combat = true; + break; + } + } + assert!( + any_combat, + "combat-enabled role must produce CombatCapability for at least one seed" + ); + } + + #[test] + fn combat_trained_flag_matches_combat_capability_presence() { + // #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability. + // If CombatCapability is present, combat_trained must be true and vice versa. + let role = minimal_role(); // combat_enabled = true + for seed in 0..50_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + let skills = world.get::<SkillSet>(entity).unwrap(); + let has_cap = world.get::<CombatCapability>(entity).is_some(); + assert_eq!( + skills.combat_trained, has_cap, + "seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}", + skills.combat_trained, has_cap + ); + } + } + + #[test] + fn combat_capability_proficiency_in_valid_range() { + // #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills(). + let role = minimal_role(); // combat_enabled = true + for seed in 0..100_u64 { + let mut world = World::new(); + let mut rng = make_rng(seed); + let entity = generate_npc(&role, &mut world, &mut rng); + if let Some(cap) = world.get::<CombatCapability>(entity) { + assert!( + cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8, + "seed {seed}: weapon_proficiency {} out of valid range 3-8", + cap.weapon_proficiency + ); + } + } + } + // ----------------------------------------------------------------------- // ActiveSim tier // ----------------------------------------------------------------------- diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index 6294ab91b..9df5d6087 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,6 +2,8 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod awareness; +pub mod background; pub mod disclosure; pub mod generate; pub mod interaction; @@ -11,6 +13,7 @@ pub mod routine; pub mod tell_state; pub mod tolerance; pub mod trait_modifiers; +pub mod vision; use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -29,6 +32,8 @@ impl Plugin for NpcPlugin { fn build(&self, app: &mut App) { app.init_resource::<relationships::RelationshipGraph>() .init_resource::<relationships::TrustEventQueue>() + .init_resource::<relationships::PropagationQueue>() + .init_resource::<relationships::DelayedTrustQueue>() .init_resource::<crate::knowledge::KnowledgeEventQueue>() .init_resource::<routine::PreviousDayPhase>() .init_resource::<tolerance::ToleranceBreachEventQueue>() @@ -49,6 +54,9 @@ impl Plugin for NpcPlugin { .after(crate::simulation::dialogue::process_confrontation_response) .after(crate::simulation::dialogue::process_dialogue_response) .before(crate::simulation::time::advance_tick), + relationships::propagate_social_actions + .after(relationships::update_trust) + .before(crate::simulation::time::advance_tick), relationships::update_relationship_dynamics .after(relationships::update_trust) .before(crate::simulation::time::advance_tick), @@ -65,11 +73,29 @@ impl Plugin for NpcPlugin { .after(mood::update_mood) .after(routine::detect_routine_deviation) .before(crate::perception::observer::compute_observer_snapshot), + background::background_tick + .after(crate::simulation::movement::validate_movement) + .before(crate::simulation::time::advance_tick), disclosure::derive_disclosure_candidates .before(crate::perception::observer::compute_observer_snapshot), disclosure::process_unprompted_disclosure .after(disclosure::derive_disclosure_candidates) .before(crate::perception::observer::compute_observer_snapshot), + // NPC player-awareness (#244) + awareness::detect_player_awareness + .after(vision::compute_npc_vision) + .before(tolerance::check_tolerance_threshold), + // NPC vision system (#115, D-011) + vision::compute_npc_vision + .after(crate::simulation::movement::validate_movement) + .after(crate::simulation::tier::update_tier_markers) + .before(crate::perception::observer::compute_observer_snapshot), + vision::emit_npc_vision_events + .after(vision::compute_npc_vision) + .before(crate::knowledge::events::process_knowledge_events), + vision::degrade_npc_inferences + .after(vision::emit_npc_vision_events) + .before(crate::simulation::time::advance_tick), crate::simulation::dialogue::process_talk_interaction .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_walk_away @@ -268,6 +294,30 @@ pub struct Contentment { pub level: i16, // -100..+100, integer for determinism (D-010) } +// --------------------------------------------------------------------------- +// Job performance (D-026 background state machine — feeds from Contentment) +// --------------------------------------------------------------------------- + +/// Tracks how well an NPC performs their job role. +/// +/// Drifts based on `Contentment` level in the background-tier state machine: +/// - contentment > 0 → score increases toward 100 +/// - contentment < 0 → score decreases toward 0 +/// - contentment = 0 → no change +/// +/// Updated by `background::background_tick` once per game-minute for +/// Background-tier NPCs. Persists through tier promotions (D-026). +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct JobPerformance { + pub score: i16, // 0..=100, integer for determinism (D-010) +} + +impl Default for JobPerformance { + fn default() -> Self { + Self { score: 50 } + } +} + // --------------------------------------------------------------------------- // Supporting axis 1: Personality traits (D-024) // --------------------------------------------------------------------------- diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index 758d4ab9d..0c6a063dc 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -141,7 +141,8 @@ impl RelationshipGraph { } /// Get all entities who have feelings about a target. - /// O(N) full scan of all edges — use for event detection, not per-tick queries. + /// O(N) full scan of all edges. Called once per game-minute (every 10 ticks) + /// by the pressure system — acceptable at v0.1 NPC counts. pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { self.edges .iter() @@ -201,16 +202,121 @@ impl RelationshipGraph { // System: update_trust (#324) // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Social propagation (#249, D-029) +// --------------------------------------------------------------------------- + +/// Minimum absolute trust required on a relationship edge for that edge +/// to carry propagation (D-029: "strong relationships", trust > 3). +pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3; + +/// Delay in ticks before third-order propagation is applied. +/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute). +pub const THIRD_ORDER_DELAY_TICKS: u64 = 30; + +/// A first-order trust change that needs social propagation. +/// +/// Produced by `update_trust` for each processed event, consumed by +/// `propagate_social_actions` to fan out second- and third-order effects. +#[derive(Debug, Clone)] +pub struct PropagationEvent { + /// The NPC directly affected by the player action (first-order subject). + pub npc: StableId, + /// The player entity (target of how NPCs feel). + pub player: StableId, + /// The first-order delta (same as what was applied to the graph). + pub delta: i8, +} + +/// Queue of propagation events emitted by `update_trust`. +#[derive(Resource, Default)] +pub struct PropagationQueue { + events: Vec<PropagationEvent>, +} + +impl PropagationQueue { + pub fn push(&mut self, event: PropagationEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec<PropagationEvent> { + std::mem::take(&mut self.events) + } +} + +/// A deferred trust change for third-order propagation. +#[derive(Debug, Clone)] +pub struct DelayedTrustChange { + /// NPC whose trust toward the player will be adjusted. + pub npc: StableId, + /// Player entity. + pub player: StableId, + /// Scaled delta to apply (already clamped to meaningful range). + pub delta: i8, + /// Tick at which this change should be applied. + pub apply_at_tick: u64, +} + +/// Queue of deferred third-order trust changes. +#[derive(Resource, Default)] +pub struct DelayedTrustQueue { + pending: Vec<DelayedTrustChange>, +} + +impl DelayedTrustQueue { + pub fn push(&mut self, change: DelayedTrustChange) { + self.pending.push(change); + } + + /// Drain changes that are due at or before `current_tick`. + pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> { + let mut due = Vec::new(); + let mut remaining = Vec::new(); + for change in self.pending.drain(..) { + if change.apply_at_tick <= current_tick { + due.push(change); + } else { + remaining.push(change); + } + } + self.pending = remaining; + due + } + + /// Number of pending deferred changes. + pub fn pending_count(&self) -> usize { + self.pending.len() + } +} + +/// Scale a first-order delta by a propagation factor, using integer arithmetic +/// (D-010: integer-only determinism). Returns 0 when the scaled value would +/// round to zero — small deltas naturally attenuate to nothing. +/// +/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%). +/// +/// Rounding: away from zero (ceiling of abs value, preserving sign). +fn scale_delta(delta: i8, factor_tenths: i8) -> i8 { + // Multiply by factor, round up (ceiling of absolute value) + let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10; + let scaled = scaled_abs.min(10) as i8; + if delta < 0 { -(scaled as i8) } else { scaled as i8 } +} + /// Drain pending trust events and apply deltas to the RelationshipGraph. /// /// Each event adjusts the NPC→player trust edge. If no edge exists, /// one is created with default Colleague kind and trust 0 before applying /// the delta. Trust is clamped to [-10, +10] per D-010. /// +/// Also queues a `PropagationEvent` for each processed event so that +/// `propagate_social_actions` can fan out second- and third-order effects. +/// /// System ordering: after dialogue systems (which emit the events), /// before advance_tick. pub fn update_trust( mut queue: ResMut<TrustEventQueue>, + mut propagation_queue: ResMut<PropagationQueue>, mut graph: ResMut<RelationshipGraph>, registry: Res<EntityRegistry>, time: Res<SimulationTime>, @@ -244,6 +350,116 @@ pub fn update_trust( new_trust = edge.trust, "Trust updated" ); + + // Queue propagation event for second/third-order effects (#249) + propagation_queue.push(PropagationEvent { + npc: npc_sid, + player: player_sid, + delta, + }); + } +} + +/// System: fan out player-action trust changes through the social graph (#249, D-029). +/// +/// Processes `PropagationQueue` events (produced by `update_trust`) and applies: +/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC +/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold +/// +/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops. +/// Propagation topology varies per seed (D-029 anti-metagaming property) because +/// the `RelationshipGraph` is seeded differently per run. +/// +/// System ordering: after `update_trust`, before `advance_tick`. +pub fn propagate_social_actions( + mut propagation_queue: ResMut<PropagationQueue>, + mut delay_queue: ResMut<DelayedTrustQueue>, + mut graph: ResMut<RelationshipGraph>, + time: Res<SimulationTime>, +) { + // --- Apply any due delayed third-order changes first --- + for change in delay_queue.drain_due(time.tick) { + if change.delta != 0 { + graph.ensure_edge(change.npc, change.player, time.tick); + graph.adjust_trust(&change.npc, &change.player, change.delta); + tracing::trace!( + npc = change.npc.0, + player = change.player.0, + delta = change.delta, + "Third-order trust propagated (delayed)" + ); + } + } + + // --- Fan out new propagation events --- + for event in propagation_queue.drain() { + let PropagationEvent { npc, player, delta } = event; + + // Visited set prevents cycles (D-029) + let mut visited = std::collections::BTreeSet::new(); + visited.insert(npc); + + // --- Second-order (immediate, 40% of delta) --- + let second_delta = scale_delta(delta, 4); // 40% + if second_delta != 0 { + // Find NPCs that the first-order NPC trusts strongly + let second_order: Vec<StableId> = graph + .relationships_of(&npc) + .into_iter() + .filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD) + .map(|(target, _)| *target) + .collect(); + + for second in &second_order { + if visited.contains(second) { + continue; + } + visited.insert(*second); + graph.ensure_edge(*second, player, time.tick); + graph.adjust_trust(second, &player, second_delta); + tracing::trace!( + first_order_npc = npc.0, + second_order_npc = second.0, + player = player.0, + delta = second_delta, + "Second-order trust propagated" + ); + } + + // --- Third-order (delayed 30 ticks, 15% of delta) --- + let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx) + if third_delta != 0 { + let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS; + for second in &second_order { + let third_order: Vec<StableId> = graph + .relationships_of(second) + .into_iter() + .filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD) + .map(|(target, _)| *target) + .collect(); + + for third in third_order { + if visited.contains(&third) { + continue; + } + visited.insert(third); + delay_queue.push(DelayedTrustChange { + npc: third, + player, + delta: third_delta, + apply_at_tick: apply_at, + }); + tracing::trace!( + third_order_npc = third.0, + player = player.0, + delta = third_delta, + apply_at, + "Third-order trust queued for delayed propagation" + ); + } + } + } + } } } @@ -493,6 +709,7 @@ mod tests { world.init_resource::<EntityRegistry>(); world.init_resource::<RelationshipGraph>(); world.init_resource::<TrustEventQueue>(); + world.init_resource::<PropagationQueue>(); world } @@ -748,6 +965,380 @@ mod tests { assert_eq!(edge.trust, 6); // 5 + 1 } + // -- PropagationQueue tests (#249) ---------------------------------------- + + #[test] + fn propagation_queue_push_and_drain() { + let mut q = PropagationQueue::default(); + q.push(PropagationEvent { + npc: StableId(1), + player: StableId(2), + delta: 1, + }); + q.push(PropagationEvent { + npc: StableId(3), + player: StableId(2), + delta: -2, + }); + let drained = q.drain(); + assert_eq!(drained.len(), 2); + assert!(q.drain().is_empty()); + } + + #[test] + fn delayed_trust_queue_drain_due_filters_by_tick() { + let mut q = DelayedTrustQueue::default(); + q.push(DelayedTrustChange { + npc: StableId(1), + player: StableId(10), + delta: 1, + apply_at_tick: 50, + }); + q.push(DelayedTrustChange { + npc: StableId(2), + player: StableId(10), + delta: -1, + apply_at_tick: 100, + }); + + // Only change at tick 50 is due at tick 60 + let due = q.drain_due(60); + assert_eq!(due.len(), 1); + assert_eq!(due[0].npc, StableId(1)); + // Change at tick 100 is still pending + assert_eq!(q.pending_count(), 1); + + // At tick 100 it becomes due + let due2 = q.drain_due(100); + assert_eq!(due2.len(), 1); + assert_eq!(due2[0].npc, StableId(2)); + assert_eq!(q.pending_count(), 0); + } + + #[test] + fn scale_delta_forty_percent() { + // factor_tenths=4 → 40% + assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1 + assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1 + assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2 + assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4 + assert_eq!(scale_delta(-2, 4), -1); // negative preserved + } + + #[test] + fn scale_delta_zero_when_too_small() { + // delta=0 → always 0 + assert_eq!(scale_delta(0, 4), 0); + } + + // -- propagate_social_actions system tests (#249) ------------------------- + + fn setup_propagation_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::<SimulationTime>(); + world.init_resource::<RelationshipGraph>(); + world.init_resource::<PropagationQueue>(); + world.init_resource::<DelayedTrustQueue>(); + world + } + + #[test] + fn second_order_trust_propagates_immediately() { + // Spec (#249, D-029): NPCs strongly connected to the first-order NPC + // receive 40% of the delta in the same tick. + // + // Graph: A → B (trust 5, > threshold 3) + // A → player (will be first-order) + // Event: player action affects A (delta=+2) + // Expected: B gains 40% of 2 = 1 (ceil) toward player + let mut world = setup_propagation_world(); + + let npc_a = StableId(1); + let npc_b = StableId(2); + let player = StableId(99); + + // A strongly trusts B (A→B trust=5) + world.resource_mut::<RelationshipGraph>().set_relationship( + npc_a, + npc_b, + make_edge(RelationshipKind::Friend, 5), + ); + + // Queue propagation from A + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: 2, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + // B should now have a trust edge toward the player (positive) + let graph = world.resource::<RelationshipGraph>(); + let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player"); + assert!( + edge.trust > 0, + "Second-order NPC should trust player more after positive first-order event" + ); + } + + #[test] + fn weak_relationship_does_not_propagate() { + // Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation. + // + // Graph: A → B (trust 2, ≤ threshold 3) + // Event: player action affects A (delta=+5) + // Expected: B gets no propagation (trust ≤ threshold) + let mut world = setup_propagation_world(); + + let npc_a = StableId(1); + let npc_b = StableId(2); + let player = StableId(99); + + // A weakly trusts B (below threshold) + world.resource_mut::<RelationshipGraph>().set_relationship( + npc_a, + npc_b, + make_edge(RelationshipKind::Colleague, 2), + ); + + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: 5, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + // B should NOT have any edge to the player + let graph = world.resource::<RelationshipGraph>(); + assert!( + graph.get_relationship(&npc_b, &player).is_none(), + "Weak relationship should not carry propagation" + ); + } + + #[test] + fn third_order_trust_is_deferred_by_thirty_ticks() { + // Spec (#249): Third-order changes are queued for 30 ticks in the future. + // + // Graph: A → B (trust 5), B → C (trust 5) + // Event: player action affects A (delta=+5) + // Expected: C's change is queued for tick+30, not applied immediately + let mut world = setup_propagation_world(); + world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick + + let npc_a = StableId(1); + let npc_b = StableId(2); + let npc_c = StableId(3); + let player = StableId(99); + + let mut graph = world.resource_mut::<RelationshipGraph>(); + graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5)); + graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5)); + + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: 5, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + // C should NOT have an edge yet (it's deferred) + { + let graph = world.resource::<RelationshipGraph>(); + assert!( + graph.get_relationship(&npc_c, &player).is_none(), + "Third-order changes should be deferred, not applied immediately" + ); + } + + // Delay queue should have one pending change for C at tick 10+30=40 + let delay_queue = world.resource::<DelayedTrustQueue>(); + assert_eq!(delay_queue.pending_count(), 1); + } + + #[test] + fn delayed_changes_applied_when_due() { + // Once the tick advances past apply_at_tick, the delayed change is applied. + let mut world = setup_propagation_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + let npc_a = StableId(1); + let npc_b = StableId(2); + let npc_c = StableId(3); + let player = StableId(99); + + let mut graph = world.resource_mut::<RelationshipGraph>(); + graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5)); + graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5)); + + // First run: queue the third-order change + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: 5, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + // Advance tick to 40 (past apply_at_tick = 40) + world.resource_mut::<SimulationTime>().tick = 40; + schedule.run(&mut world); + + // C should now have an edge with positive trust + let graph = world.resource::<RelationshipGraph>(); + let edge = graph.get_relationship(&npc_c, &player) + .expect("Delayed change should have been applied by now"); + assert!(edge.trust > 0, "Third-order trust should be positive after delayed application"); + } + + #[test] + fn cycle_prevention_no_a_to_b_to_a_loop() { + // Spec (#249, D-029): visited set prevents A→B→A cycles. + // + // Graph: A ↔ B (both trust each other at 5) + // Event: player action affects A (delta=+2) + // A should propagate to B, but B should NOT propagate back to A. + let mut world = setup_propagation_world(); + + let npc_a = StableId(1); + let npc_b = StableId(2); + let player = StableId(99); + + // Bidirectional strong trust + world.resource_mut::<RelationshipGraph>().set_relationship( + npc_a, + npc_b, + make_edge(RelationshipKind::Friend, 5), + ); + world.resource_mut::<RelationshipGraph>().set_relationship( + npc_b, + npc_a, + make_edge(RelationshipKind::Friend, 5), + ); + + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: 2, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + // A should not get a second-order change from the cycle (A was the origin) + let graph = world.resource::<RelationshipGraph>(); + // The only A→player edge effect should be through the propagation event + // (no direct creation in propagate_social_actions — only update_trust does that). + // B should have an edge to player. + assert!( + graph.get_relationship(&npc_b, &player).is_some(), + "B should get second-order propagation from A" + ); + // A should NOT have a re-propagated edge (cycle prevented) + assert!( + graph.get_relationship(&npc_a, &player).is_none(), + "A should not receive back-propagation from B (cycle prevention)" + ); + } + + #[test] + fn negative_delta_propagates_as_negative() { + // Spec (#249): Negative deltas (walk-away, confrontation) also propagate + // with the same sign. + let mut world = setup_propagation_world(); + + let npc_a = StableId(1); + let npc_b = StableId(2); + let player = StableId(99); + + world.resource_mut::<RelationshipGraph>().set_relationship( + npc_a, + npc_b, + make_edge(RelationshipKind::Friend, 5), + ); + + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: -2, // confrontation + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + // B should trust player LESS after A was confronted + let graph = world.resource::<RelationshipGraph>(); + let edge = graph.get_relationship(&npc_b, &player) + .expect("B should have edge to player"); + assert!( + edge.trust < 0, + "Negative propagation should reduce B's trust in player" + ); + } + + #[test] + fn no_relationships_means_no_propagation() { + // If the first-order NPC has no relationships, the queue is drained + // but nothing propagates. + let mut world = setup_propagation_world(); + + let npc_a = StableId(1); + let player = StableId(99); + + world.resource_mut::<PropagationQueue>().push(PropagationEvent { + npc: npc_a, + player, + delta: 5, + }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(propagate_social_actions); + schedule.run(&mut world); + + let graph = world.resource::<RelationshipGraph>(); + assert!(graph.is_empty(), "No relationships → no propagation edges created"); + + let delay_queue = world.resource::<DelayedTrustQueue>(); + assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either"); + } + + #[test] + fn update_trust_populates_propagation_queue() { + // Integration: update_trust should push to PropagationQueue for downstream #249. + let mut world = setup_trust_world(); + + let npc = world.spawn_empty().id(); + let player = world.spawn_empty().id(); + world.resource_mut::<EntityRegistry>().register(npc); + world.resource_mut::<EntityRegistry>().register(player); + + world + .resource_mut::<TrustEventQueue>() + .push(TrustEvent::TalkCompleted { npc, player }); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_trust); + schedule.run(&mut world); + + // PropagationQueue should have 1 event (for downstream propagation) + let prop_events = world.resource_mut::<PropagationQueue>().drain(); + assert_eq!(prop_events.len(), 1); + assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA); + } + #[test] fn unregistered_entity_event_is_skipped() { let mut world = setup_trust_world(); diff --git a/server/src/npc/vision.rs b/server/src/npc/vision.rs new file mode 100644 index 000000000..a095c5ab4 --- /dev/null +++ b/server/src/npc/vision.rs @@ -0,0 +1,826 @@ +//! NPC vision system (#115, D-011). +//! +//! Active-tier NPCs use the same symmetric shadowcasting as the player. +//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known +//! positions and zone inferences ("saw you enter building → knows you're +//! inside"). +//! +//! ## System ordering +//! +//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot +//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events +//! 3. `degrade_npc_inferences` — once per game-minute (D-031) +//! +//! ## Performance +//! +//! 30–80 Active NPCs × symmetric shadowcast per tick. Confirmed within +//! D-026 Active tier budget by architecture review. + +use std::collections::{BTreeMap, BTreeSet}; + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::types::StableId; +use crate::knowledge::{ + EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph, +}; +use crate::npc::Npc; +use crate::perception::shadowcast::compute_fov; +use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig}; +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex}; +use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE}; +use crate::simulation::tier::ActiveSim; + +/// NPC vision range in tiles (matches player forward range from VisionConeConfig). +pub const NPC_VISION_RANGE: i32 = 20; + +/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour. +pub const INFERENCE_DEGRADE_TICKS: u64 = 600; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Current field-of-view results for an NPC. +/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010). +#[derive(Component, Debug, Clone, Default)] +pub struct NpcVisionState { + /// StableIds of entities currently in this NPC's LOS. + pub visible_entities: BTreeSet<StableId>, + /// Whether the player character is currently visible. + pub player_visible: bool, +} + +/// Persistent memory of entities this NPC has seen. +/// Survives after entities leave LOS (D-011: "saw you enter building → +/// knows you're inside"). +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct NpcMemory { + /// Last known position + tick for entities this NPC has seen. + /// Key: StableId of the observed entity. + pub last_known: BTreeMap<StableId, LastKnownEntry>, +} + +/// Record of last-known position for a single entity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LastKnownEntry { + /// Position where entity was last seen. + pub position: TilePosition, + /// Tick when the entity was last observed. + pub observed_tick: u64, + /// Zone inference: if entity was last seen before leaving LOS, + /// the NPC infers they are still nearby. + pub zone_inference: Option<ZoneInference>, +} + +/// Inference that an entity is still near a position based on last observation. +/// Degrades after `degrades_at_tick`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZoneInference { + /// The position where the entity was last seen. + pub last_seen_position: TilePosition, + /// Tick when the entity was seen at this position. + pub observed_tick: u64, + /// Tick at which this inference degrades (NPC stops assuming entity is here). + pub degrades_at_tick: u64, +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// Compute NPC field-of-view for all Active-tier NPCs. +/// +/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011), +/// apply vision cone if the NPC has a `Facing` component, then check which entities +/// from the spatial index are at visible tiles. +pub fn compute_npc_vision( + walkability: Option<Res<WalkabilityMap>>, + registry: Res<EntityRegistry>, + spatial_index: Res<NaiveSpatialIndex>, + player_query: Query<Entity, With<PlayerCharacter>>, + mut npc_query: Query< + (Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState), + (With<Npc>, With<ActiveSim>), + >, + entity_positions: Query<&TilePosition>, +) { + let Some(walkability) = walkability else { + return; + }; + let player_entity = player_query.iter().next(); + let player_stable_id = player_entity.and_then(|e| registry.to_stable(e)); + let config = VisionConeConfig::default(); + + for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() { + let z = npc_pos.z; + + // Run symmetric shadowcasting — same algorithm as player (D-011, D-035) + let fov = compute_fov( + |x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)), + npc_pos.x, + npc_pos.y, + NPC_VISION_RANGE, + z, + ); + + // Collect visible tile positions — apply vision cone if NPC has facing + let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing { + let cone_tiles = + apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config); + cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect() + } else { + // No facing → omnidirectional vision (full FOV) + fov.visible_tiles().collect() + }; + + // Find entities at visible positions via spatial index + let mut new_visible = BTreeSet::new(); + let mut player_vis = false; + + // Single-pass query: all entities within vision range including own tile + let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32); + + for entity in candidates { + if entity == npc_entity { + continue; + } + let Ok(entity_pos) = entity_positions.get(entity) else { + continue; + }; + if entity_pos.z != z { + continue; + } + if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) { + continue; + } + let Some(stable_id) = registry.to_stable(entity) else { + continue; + }; + + new_visible.insert(stable_id); + if Some(stable_id) == player_stable_id { + player_vis = true; + } + } + + vision_state.visible_entities = new_visible; + vision_state.player_visible = player_vis; + } +} + +/// Emit knowledge events when NPCs gain or lose sight of the player. +/// +/// Mirrors the player's `emit_observation_events` pattern but scoped to +/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState` +/// for direct query by downstream systems (#244 awareness) without flooding +/// the knowledge event queue. +/// +/// Also updates `NpcMemory` with last-known positions and zone inferences. +pub fn emit_npc_vision_events( + time: Res<SimulationTime>, + registry: Res<EntityRegistry>, + mut event_queue: ResMut<KnowledgeEventQueue>, + player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>, + mut npc_query: Query< + ( + Entity, + &NpcVisionState, + &mut NpcMemory, + Option<&KnowledgeGraph>, + ), + (With<Npc>, With<ActiveSim>), + >, +) { + let Ok((player_entity, player_pos)) = player_query.single() else { + return; + }; + let Some(player_sid) = registry.to_stable(player_entity) else { + return; + }; + + for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() { + if vision_state.player_visible { + // Player is in LOS — update memory and emit DirectObservation + memory.last_known.insert( + player_sid, + LastKnownEntry { + position: *player_pos, + observed_tick: time.tick, + zone_inference: None, // Active observation clears inference + }, + ); + + event_queue.push(KnowledgeEvent { + observer: npc_entity, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target: player_entity, + position: *player_pos, + }, + }); + } else { + // Player NOT in LOS — check if they WERE Direct (just left) + let was_direct = knowledge_graph + .and_then(|kg| { + kg.entity_knowledge(&player_sid) + .map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct) + }) + .unwrap_or(false); + + if was_direct { + // Player just left this NPC's LOS — emit LeftLOS + event_queue.push(KnowledgeEvent { + observer: npc_entity, + tick: time.tick, + event_type: KnowledgeEventType::LeftLOS { + target: player_entity, + }, + }); + + // Create zone inference — NPC remembers where they last saw the player + if let Some(entry) = memory.last_known.get_mut(&player_sid) { + entry.zone_inference = Some(ZoneInference { + last_seen_position: entry.position, + observed_tick: entry.observed_tick, + degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS, + }); + } + } + } + } +} + +/// Degrade stale zone inferences in NPC memory. +/// +/// Runs once per game-minute (every 10 ticks per D-031). When a zone +/// inference passes its degradation tick, the inference is removed. +pub fn degrade_npc_inferences( + time: Res<SimulationTime>, + mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>, +) { + if time.tick % TICKS_PER_GAME_MINUTE != 0 { + return; + } + + for mut memory in npc_query.iter_mut() { + for entry in memory.last_known.values_mut() { + if let Some(ref inference) = entry.zone_inference { + if time.tick >= inference.degrades_at_tick { + entry.zone_inference = None; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::{EntityRegistry, KnowledgeGraph}; + use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; + use bevy_ecs::world::World; + + fn setup_world(width: i32, height: i32) -> World { + let mut world = World::new(); + world.insert_resource(SimulationTime::default()); + world.insert_resource(WalkabilityMap::new(width, height, 1)); + world.init_resource::<KnowledgeEventQueue>(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<NaiveSpatialIndex>(); + world.init_resource::<VisibilityGeometry>(); + world.init_resource::<ActivePerceptionMode>(); + world + } + + fn pos(x: i32, y: i32) -> TilePosition { + TilePosition::new(x, y, 0) + } + + // --- compute_npc_vision tests --- + + #[test] + fn npc_sees_nearby_entity_in_open_field() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + // NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + let npc_sid = registry.register(npc); + spatial.update(npc, pos(16, 16)); + + let target = world.spawn(pos(16, 14)).id(); + let target_sid = registry.register(target); + spatial.update(target, pos(16, 14)); + + // Player entity (required for player_stable_id lookup) + let player = world.spawn((PlayerCharacter, pos(0, 0))).id(); + registry.register(player); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + vision.visible_entities.contains(&target_sid), + "NPC should see nearby entity in open field" + ); + assert!(!vision.player_visible, "player is far away"); + let _ = npc_sid; // registered for completeness + } + + #[test] + fn npc_cannot_see_through_wall() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + // Target behind a wall + let target = world.spawn(pos(16, 14)).id(); + let target_sid = registry.register(target); + spatial.update(target, pos(16, 14)); + + // Wall between NPC and target + let mut walkability = world.resource_mut::<WalkabilityMap>(); + walkability.set_walkable(&pos(16, 15), false); + drop(walkability); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + !vision.visible_entities.contains(&target_sid), + "NPC should not see entity behind wall" + ); + } + + #[test] + fn npc_detects_player_visible() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + let player = world.spawn((PlayerCharacter, pos(16, 14))).id(); + registry.register(player); + spatial.update(player, pos(16, 14)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!(vision.player_visible, "NPC should detect player in LOS"); + } + + #[test] + fn npc_does_not_see_entity_on_different_z_level() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + // Target at same x/y but different z + let target = world.spawn(TilePosition::new(16, 14, 1)).id(); + let target_sid = registry.register(target); + spatial.update(target, TilePosition::new(16, 14, 1)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + !vision.visible_entities.contains(&target_sid), + "NPC should not see entity on different z-level" + ); + } + + #[test] + fn npc_does_not_see_entity_beyond_range() { + let mut world = setup_world(64, 64); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + // Target beyond NPC_VISION_RANGE (20 tiles) + let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id(); + let target_sid = registry.register(target); + spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + !vision.visible_entities.contains(&target_sid), + "NPC should not see entity beyond vision range" + ); + } + + #[test] + fn npc_does_not_see_self() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + let npc_sid = registry.register(npc); + spatial.update(npc, pos(16, 16)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + !vision.visible_entities.contains(&npc_sid), + "NPC should not include itself in visible entities" + ); + } + + #[test] + fn npc_sees_non_npc_entity_on_same_tile() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + // NPC at (16, 16) + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default())) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + // Non-NPC entity on the same tile (e.g. dropped item) + let item = world.spawn(pos(16, 16)).id(); + let item_sid = registry.register(item); + spatial.update(item, pos(16, 16)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + vision.visible_entities.contains(&item_sid), + "NPC should see non-NPC entity sharing its tile" + ); + } + + #[test] + fn background_npc_not_processed() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + let mut spatial = NaiveSpatialIndex::new(); + + // Background NPC — should NOT have its vision computed + let npc = world + .spawn(( + Npc, + crate::simulation::tier::BackgroundSim, + pos(16, 16), + NpcVisionState::default(), + )) + .id(); + registry.register(npc); + spatial.update(npc, pos(16, 16)); + + let target = world.spawn(pos(16, 14)).id(); + let target_sid = registry.register(target); + spatial.update(target, pos(16, 14)); + + world.insert_resource(registry); + world.insert_resource(spatial); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(compute_npc_vision); + schedule.run(&mut world); + + let vision = world.get::<NpcVisionState>(npc).unwrap(); + assert!( + vision.visible_entities.is_empty(), + "Background NPC should not have vision computed" + ); + let _ = target_sid; + } + + // --- emit_npc_vision_events tests --- + + #[test] + fn npc_seeing_player_emits_direct_observation() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world.spawn((PlayerCharacter, pos(16, 14))).id(); + let player_sid = registry.register(player); + + let mut vision = NpcVisionState::default(); + vision.visible_entities.insert(player_sid); + vision.player_visible = true; + + let npc = world + .spawn(( + Npc, + ActiveSim, + pos(16, 16), + vision, + NpcMemory::default(), + KnowledgeGraph::new(), + )) + .id(); + registry.register(npc); + + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(emit_npc_vision_events); + schedule.run(&mut world); + + let queue = world.resource::<KnowledgeEventQueue>(); + assert!(!queue.is_empty(), "should emit DirectObservation for player"); + + let event = &queue.events[0]; + assert_eq!(event.observer, npc); + assert!(matches!( + event.event_type, + KnowledgeEventType::DirectObservation { target, .. } if target == player + )); + } + + #[test] + fn npc_losing_player_emits_left_los() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world.spawn((PlayerCharacter, pos(16, 14))).id(); + let player_sid = registry.register(player); + + // NPC that had Direct knowledge of player (player was just visible) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(player_sid, pos(16, 14), 50); + + // Memory with last known position + let mut memory = NpcMemory::default(); + memory.last_known.insert( + player_sid, + LastKnownEntry { + position: pos(16, 14), + observed_tick: 50, + zone_inference: None, + }, + ); + + // Vision state: player NOT visible now + let vision = NpcVisionState::default(); + + let npc = world + .spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg)) + .id(); + registry.register(npc); + + world.insert_resource(registry); + + let mut time = SimulationTime::default(); + time.tick = 60; + world.insert_resource(time); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(emit_npc_vision_events); + schedule.run(&mut world); + + let queue = world.resource::<KnowledgeEventQueue>(); + let has_left_los = queue.events.iter().any(|e| { + matches!( + e.event_type, + KnowledgeEventType::LeftLOS { target } if target == player + ) + }); + assert!(has_left_los, "should emit LeftLOS when player leaves NPC LOS"); + + // Check zone inference was created + let npc_memory = world.get::<NpcMemory>(npc).unwrap(); + let entry = npc_memory.last_known.get(&player_sid).unwrap(); + assert!( + entry.zone_inference.is_some(), + "zone inference should be created when player leaves LOS" + ); + assert_eq!( + entry.zone_inference.as_ref().unwrap().degrades_at_tick, + 60 + INFERENCE_DEGRADE_TICKS + ); + } + + #[test] + fn npc_updates_memory_on_player_observation() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world.spawn((PlayerCharacter, pos(10, 10))).id(); + let player_sid = registry.register(player); + + let mut vision = NpcVisionState::default(); + vision.visible_entities.insert(player_sid); + vision.player_visible = true; + + let npc = world + .spawn(( + Npc, + ActiveSim, + pos(16, 16), + vision, + NpcMemory::default(), + KnowledgeGraph::new(), + )) + .id(); + registry.register(npc); + + let mut time = SimulationTime::default(); + time.tick = 100; + world.insert_resource(time); + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(emit_npc_vision_events); + schedule.run(&mut world); + + let memory = world.get::<NpcMemory>(npc).unwrap(); + let entry = memory.last_known.get(&player_sid).unwrap(); + assert_eq!(entry.position, pos(10, 10)); + assert_eq!(entry.observed_tick, 100); + assert!(entry.zone_inference.is_none(), "active observation = no inference"); + } + + // --- degrade_npc_inferences tests --- + + #[test] + fn inference_degrades_after_threshold() { + let mut world = setup_world(32, 32); + + let player_sid = StableId(1); + let mut memory = NpcMemory::default(); + memory.last_known.insert( + player_sid, + LastKnownEntry { + position: pos(10, 10), + observed_tick: 50, + zone_inference: Some(ZoneInference { + last_seen_position: pos(10, 10), + observed_tick: 50, + degrades_at_tick: 100, + }), + }, + ); + + let npc = world.spawn((Npc, ActiveSim, memory)).id(); + + // Tick 90 (before degradation, on minute boundary) + let mut time = SimulationTime::default(); + time.tick = 90; + world.insert_resource(time); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(degrade_npc_inferences); + schedule.run(&mut world); + + let mem = world.get::<NpcMemory>(npc).unwrap(); + assert!( + mem.last_known[&player_sid].zone_inference.is_some(), + "inference should survive before degradation tick" + ); + + // Tick 100 (degradation tick, on minute boundary) + let mut time = SimulationTime::default(); + time.tick = 100; + world.insert_resource(time); + + let mut schedule2 = bevy_ecs::schedule::Schedule::default(); + schedule2.add_systems(degrade_npc_inferences); + schedule2.run(&mut world); + + let mem = world.get::<NpcMemory>(npc).unwrap(); + assert!( + mem.last_known[&player_sid].zone_inference.is_none(), + "inference should be removed at degradation tick" + ); + } + + #[test] + fn inference_degradation_skips_non_minute_ticks() { + let mut world = setup_world(32, 32); + + let player_sid = StableId(1); + let mut memory = NpcMemory::default(); + memory.last_known.insert( + player_sid, + LastKnownEntry { + position: pos(10, 10), + observed_tick: 50, + zone_inference: Some(ZoneInference { + last_seen_position: pos(10, 10), + observed_tick: 50, + degrades_at_tick: 55, // already past + }), + }, + ); + + let npc = world.spawn((Npc, ActiveSim, memory)).id(); + + // Tick 57 — past degradation but NOT a minute boundary + let mut time = SimulationTime::default(); + time.tick = 57; + world.insert_resource(time); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(degrade_npc_inferences); + schedule.run(&mut world); + + let mem = world.get::<NpcMemory>(npc).unwrap(); + assert!( + mem.last_known[&player_sid].zone_inference.is_some(), + "degradation should not run on non-minute ticks" + ); + } + + #[test] + fn no_events_when_no_player_entity() { + let mut world = setup_world(32, 32); + let registry = EntityRegistry::new(0); + + let vision = NpcVisionState::default(); + world.spawn(( + Npc, + ActiveSim, + pos(16, 16), + vision, + NpcMemory::default(), + KnowledgeGraph::new(), + )); + + world.insert_resource(registry); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(emit_npc_vision_events); + schedule.run(&mut world); // should not panic + + let queue = world.resource::<KnowledgeEventQueue>(); + assert!(queue.is_empty(), "no events when no player entity exists"); + } +} diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index 8ea8ef2f0..cdbd52a28 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -213,6 +213,7 @@ mod tests { world.init_resource::<ObservationEventQueue>(); world.init_resource::<VisibilityGeometry>(); world.init_resource::<ActivePerceptionMode>(); + world.init_resource::<crate::content::template::TriangleCrisisEventQueue>(); world } diff --git a/server/src/perception/observation.rs b/server/src/perception/observation.rs index fcfc776bb..e00c62262 100644 --- a/server/src/perception/observation.rs +++ b/server/src/perception/observation.rs @@ -168,6 +168,7 @@ mod tests { world.init_resource::<EntityRegistry>(); world.init_resource::<VisibilityGeometry>(); world.init_resource::<ActivePerceptionMode>(); + world.init_resource::<crate::content::template::TriangleCrisisEventQueue>(); world } diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 4720e806a..011e65591 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -9,6 +9,7 @@ use bevy_ecs::prelude::*; use std::collections::BTreeSet; + use crate::bridge::types::*; use crate::knowledge::graph::filter_by_access; use crate::knowledge::types::{AccessRule, KnowledgeState}; @@ -19,12 +20,15 @@ use crate::perception::vision_cone::Facing; use crate::simulation::contraband::ScanEventBuffer; use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; +use crate::simulation::examine::ExamineResultBuffer; use crate::simulation::follow::FollowTarget; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::poi::PointOfInterest; use crate::simulation::rng::SimRng; +use crate::content::template::TriangleCrisisEventQueue; use crate::simulation::sound::SoundEventQueue; use crate::simulation::stance::Stance; use crate::simulation::time::SimulationTime; @@ -71,7 +75,7 @@ pub fn compute_observer_snapshot( Entity, &TilePosition, Option<&Facing>, - &KnowledgeGraph, + Ref<KnowledgeGraph>, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, @@ -82,6 +86,7 @@ pub fn compute_observer_snapshot( Option<&mut ScanEventBuffer>, Option<&mut ConversationEventBuffer>, Option<&FollowTarget>, + Option<&mut ExamineResultBuffer>, ), With<PlayerCharacter>, >, @@ -94,8 +99,13 @@ pub fn compute_observer_snapshot( Option<&crate::npc::tell_state::DerivedTellState>, )>, inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + poi_query: Query<&PointOfInterest>, mut buffer: ResMut<SnapshotBuffer>, + mut crisis_queue: ResMut<TriangleCrisisEventQueue>, sim_rng: Option<Res<SimRng>>, + pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>, + error_buffer: Option<ResMut<SimErrorBuffer>>, + npc_count_query: Query<Entity, With<crate::npc::Npc>>, ) { let Ok(( observer_entity, @@ -112,12 +122,17 @@ pub fn compute_observer_snapshot( mut scan_event_buffer_opt, mut conversation_buffer_opt, follow_target_opt, + mut examine_result_buffer_opt, )) = observer_query.single_mut() else { tracing::error!("compute_observer_snapshot: PlayerCharacter query failed"); return; }; + // D-041 dirty flag: only re-serialize KG dump when observer's graph changed this tick. + let kg_changed = observer_kg.is_changed(); + let observer_kg: &KnowledgeGraph = &observer_kg; + let facing = facing_opt .map(|f| f.0) .unwrap_or(FacingDirection::default()); @@ -193,6 +208,13 @@ pub fn compute_observer_snapshot( let current_monologue = monologue_buffer.take(); let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take()); + let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take()).map( + |evt| crate::bridge::types::ExamineResultWire { + entity_id: evt.target_entity_id, + text: evt.text, + confidence: crate::knowledge::types::KnowledgeConfidence::KnowsDetails, + }, + ); let scan_events = scan_event_buffer_opt .as_mut() .map(|buf| buf.take()) @@ -272,6 +294,146 @@ pub fn compute_observer_snapshot( }) }); + // Collect discovered POIs for minimap (#151, D-013) + // A POI is "discovered" if the observer's KG contains fact "poi.{poi_id}". + let poi_list: Vec<PoiWire> = poi_query + .iter() + .filter(|poi| observer_kg.knows_fact(&poi.fact_id())) + .map(|poi| PoiWire { + poi_id: poi.poi_id.clone(), + name: poi.name.clone(), + x: poi.position.x, + y: poi.position.y, + z: poi.position.z, + category: poi.category, + }) + .collect(); + + // Build player knowledge dump for journal panel (#264, D-041). + // Only re-serialize when the observer's KG was mutated this tick (Changed filter). + // When unchanged, player_knowledge = None → field omitted from wire (skip_serializing_if). + // Client keeps its last value (apply_snapshot only updates when field is present). + let player_knowledge = if kg_changed { + let kg_entities: Vec<KnownEntityWire> = observer_kg + .known_entities_iter() + .map(|(sid, ek)| { + let name = ek + .known_attributes + .get("name") + .cloned() + .unwrap_or_else(|| "Unknown".to_string()); + let source = match &ek.source { + crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => { + "DirectObservation".to_string() + } + crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(), + crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => { + format!("ToldBy({})", source_id.0) + } + crate::knowledge::types::KnowledgeSource::Inferred { .. } => { + "Inferred".to_string() + } + crate::knowledge::types::KnowledgeSource::Background => { + "Background".to_string() + } + }; + KnownEntityWire { + entity_id: sid.0, + name, + confidence: ek.confidence, + source, + state: ek.state, + relationship: ek.relationship, + last_observed_tick: ek.last_observed_tick, + } + }) + .collect(); + + let kg_facts: Vec<KnownFactWire> = observer_kg + .known_facts_iter() + .map(|(fid, fk)| { + let source = match &fk.source { + crate::knowledge::types::KnowledgeSource::DirectObservation { .. } => { + "DirectObservation".to_string() + } + crate::knowledge::types::KnowledgeSource::Heard { .. } => "Heard".to_string(), + crate::knowledge::types::KnowledgeSource::ToldBy { source_id, .. } => { + format!("ToldBy({})", source_id.0) + } + crate::knowledge::types::KnowledgeSource::Inferred { .. } => { + "Inferred".to_string() + } + crate::knowledge::types::KnowledgeSource::Background => { + "Background".to_string() + } + }; + KnownFactWire { + fact_id: fid.0.clone(), + confidence: fk.confidence, + source, + state: fk.state, + acquired_tick: fk.acquired_tick, + } + }) + .collect(); + + if kg_entities.is_empty() && kg_facts.is_empty() { + None + } else { + Some(PlayerKnowledgeWire { + entities: kg_entities, + facts: kg_facts, + }) + } + } else { + None + }; + + // Consume pending save/load result for this tick (#553). + let save_result = buffer.pending_save_result.take(); + + // Drain triangle crisis events (#250) and convert to wire format. + // Drain triangle crisis events (#250) and filter role_assignments against + // observer KG (D-010 principle 2: information boundaries are universal). + // NPCs unknown to the observer are redacted from the wire event. + let triangle_crisis_events: Vec<TriangleCrisisEventWire> = crisis_queue + .drain() + .into_iter() + .map(|e| { + let mut wire = TriangleCrisisEventWire::from(e); + wire.role_assignments.retain(|(_, npc_id)| { + observer_kg.knows_entity(&crate::knowledge::types::StableId(*npc_id)) + }); + wire + }) + .collect(); + + // Compute state hash for desync detection (#85). + // Hash inputs: player position (x, y, z), NPC count, tick number. + // Uses FNV-1a (64-bit) for determinism across Rust versions — DefaultHasher + // is explicitly prohibited by D-010 principle 4 (see template.rs module docs). + let state_hash = { + let mut hash: u64 = 0xcbf29ce484222325; // FNV-1a offset basis + let fnv_fold = |h: &mut u64, bytes: &[u8]| { + for &b in bytes { + *h ^= b as u64; + *h = h.wrapping_mul(0x100000001b3); // FNV-1a prime + } + }; + fnv_fold(&mut hash, &time.tick.to_le_bytes()); + fnv_fold(&mut hash, &observer_pos.x.to_le_bytes()); + fnv_fold(&mut hash, &observer_pos.y.to_le_bytes()); + fnv_fold(&mut hash, &observer_pos.z.to_le_bytes()); + let npc_count = npc_count_query.iter().count() as u64; + fnv_fold(&mut hash, &npc_count.to_le_bytes()); + Some(hash) + }; + + // Drain sim errors collected this tick (#85) + let sim_errors = error_buffer + .map(|mut buf| buf.drain()) + .unwrap_or_default(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -290,8 +452,18 @@ pub fn compute_observer_snapshot( conversation_events, conversation_ended, follow_state, + character_pressure: pressure_query.iter().next().map(|p| { + crate::simulation::pressure::CharacterPressureWire::from(p) + }), sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), + poi_list, + examine_result, + player_knowledge, + save_result, + triangle_crisis_events, + state_hash, + sim_errors, }); } diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index af16f5259..51ac44fc4 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -16,6 +16,7 @@ fn setup_world(width: i32, height: i32) -> World { world.init_resource::<VisibilityGeometry>(); world.init_resource::<ActivePerceptionMode>(); world.init_resource::<crate::simulation::sound::SoundEventQueue>(); + world.init_resource::<crate::content::template::TriangleCrisisEventQueue>(); world } @@ -2583,3 +2584,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() { ); } } + +// --------------------------------------------------------------------------- +// #337 — Tell state → snapshot integration (D-024 tell system) +// --------------------------------------------------------------------------- + +/// Helper: run derive_tell_state + two-stage observer pipeline together. +fn run_tell_plus_observer_pipeline(world: &mut World) { + use crate::npc::tell_state::derive_tell_state; + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(( + derive_tell_state, + compute_visibility_geometry.after(derive_tell_state), + compute_observer_snapshot + .after(compute_visibility_geometry) + .after(derive_tell_state), + )); + schedule.run(world); +} + +#[test] +fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() { + // Spec (#337, D-024): NPC with Major secret + stress past midpoint shows + // TellCategory::Nervous in ObserverSnapshot.entities[].tell_state. + // End-to-end pipeline: axis values → derive_tell_state → DerivedTellState + // → compute_observer_snapshot → VisibleEntity.tell_state. + use crate::npc::mood::{MoodState, NpcMood}; + use crate::npc::tell_state::{DerivedTellState, TellCategory}; + use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold}; + use crate::simulation::tier::ActiveSim; + + let mut world = setup_world(32, 32); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + // NPC directly north — in forward LOS — with Major secret + stress > midpoint. + // stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2) + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(16, 14, 0), + Secret { + description: "criminal record".into(), + severity: SecretSeverity::Major, + known_by: vec![], + }, + ToleranceThreshold { current_stress: 60, threshold: 100 }, + Contentment { level: 0 }, + MoodState { mood: NpcMood::Neutral, changed_tick: 0 }, + DerivedTellState::default(), + )); + + run_tell_plus_observer_pipeline(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + + let npc = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible in snapshot"); + + assert_eq!( + npc.tell_state, + Some(TellCategory::Nervous), + "NPC with Major secret + stress past midpoint should show Nervous tell in snapshot" + ); +} + +#[test] +fn tell_state_none_for_neutral_npc_in_snapshot() { + // Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot. + // Verifies the pipeline correctly omits tell when no conditions are met. + use crate::npc::mood::{MoodState, NpcMood}; + use crate::npc::tell_state::DerivedTellState; + use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold}; + use crate::simulation::tier::ActiveSim; + + let mut world = setup_world(32, 32); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + // NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment) + world.spawn(( + Npc, + ActiveSim, + TilePosition::new(16, 14, 0), + Secret { + description: "minor embarrassment".into(), + severity: SecretSeverity::Minor, + known_by: vec![], + }, + ToleranceThreshold { current_stress: 10, threshold: 100 }, + Contentment { level: 0 }, + MoodState { mood: NpcMood::Neutral, changed_tick: 0 }, + DerivedTellState::default(), + )); + + run_tell_plus_observer_pipeline(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + + let npc = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible in snapshot"); + + assert_eq!( + npc.tell_state, + None, + "neutral NPC should have no tell state in snapshot" + ); +} + +#[test] +fn tell_state_none_when_npc_has_no_derived_tell_component() { + // Spec (#337): NPC without DerivedTellState component has tell_state = None. + // Verifies Option<&DerivedTellState> query handles absent component gracefully. + + let mut world = setup_world(32, 32); + + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + // NPC with no DerivedTellState component at all + world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::<SnapshotBuffer>(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + + let npc = snapshot + .entities + .iter() + .find(|e| matches!(e.kind, EntityKind::Npc)) + .expect("NPC should be visible in snapshot"); + + assert_eq!( + npc.tell_state, + None, + "NPC without DerivedTellState component should have tell_state = None" + ); +} diff --git a/server/src/simulation/examine.rs b/server/src/simulation/examine.rs new file mode 100644 index 000000000..8a0012cd9 --- /dev/null +++ b/server/src/simulation/examine.rs @@ -0,0 +1,400 @@ +//! Examine interaction system (#242). +//! +//! Handles the Examine verb: player examines an NPC or object at close range, +//! generating character-filtered observation text and a DirectObservation +//! KnowledgeGraph entry. +//! +//! Pipeline: +//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" } +//! → process_player_input inserts ExamineRequest on player +//! → process_examine_interaction reads request, generates text, pushes KG event +//! → ExamineResultBuffer consumed by compute_observer_snapshot +//! → ObserverSnapshot.examine_result delivered to client + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::bridge::types::CharacterArchetype; +use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType}; +use crate::knowledge::EntityRegistry; +use crate::npc::mood::{MoodState, NpcMood}; +use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold}; +use crate::simulation::interaction::{ObjectType, CLOSE_RANGE}; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::time::SimulationTime; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Marker: player requested Examine interaction with a target entity this tick. +/// +/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc", +/// "Examine Object", or "ExamineObject". Consumed and removed by +/// process_examine_interaction each tick. +#[derive(Component, Debug)] +pub struct ExamineRequest { + pub target: Entity, +} + +/// Character-filtered examination result for snapshot delivery. +/// +/// Content differs per CharacterArchetype: +/// Smuggler — physical threat read, cargo-handling posture, opportunity windows. +/// Detective — procedural tells, behavioral inconsistencies, stress indicators. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExamineResultEvent { + /// Character-filtered observation text for client display. + pub text: String, + /// Wire-format entity identifier of the examined entity. + pub target_entity_id: u64, +} + +/// Authored examine text for a non-NPC entity (#246). +/// +/// Attach to any examinable object (Readable, Terminal, etc.) to provide +/// a fixed description returned when the player examines it. +/// If absent, examining a non-NPC entity returns a generic fallback. +#[derive(Component, Debug, Clone)] +pub struct ExamineText(pub String); + +/// Buffer holding the examine result for snapshot inclusion. +/// +/// Consumed once per snapshot via `take()`. Cleared at snapshot build time. +/// Attach to the player entity alongside other buffer components. +#[derive(Component, Debug, Default)] +pub struct ExamineResultBuffer { + pub(crate) result: Option<ExamineResultEvent>, +} + +impl ExamineResultBuffer { + /// Drain and return the examine result, leaving the buffer empty. + pub fn take(&mut self) -> Option<ExamineResultEvent> { + self.result.take() + } +} + +// --------------------------------------------------------------------------- +// Text generation (deterministic, integer-only — D-010) +// --------------------------------------------------------------------------- + +/// Stress ratio 0..=100 derived from ToleranceThreshold components. +/// Uses integer multiplication to avoid division by zero. +fn stress_ratio(threshold: &ToleranceThreshold) -> u8 { + if threshold.threshold <= 0 { + return 0; + } + ((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100) + as u8 +} + +/// Map NpcMood to a terse descriptor shared by both archetypes. +fn mood_word(mood: NpcMood) -> &'static str { + match mood { + NpcMood::Neutral => "neutral", + NpcMood::Anxious => "anxious", + NpcMood::Frustrated => "frustrated", + NpcMood::Content => "at ease", + NpcMood::Suspicious => "watchful", + NpcMood::Warm => "open", + NpcMood::Hostile => "hostile", + NpcMood::Focused => "focused", + } +} + +fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool { + traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false) +} + +/// Generate character-filtered examination text from NPC component state. +/// All logic is pure, deterministic, and integer-based (D-010). +pub fn generate_examine_text( + mood: NpcMood, + ratio: u8, + archetype: CharacterArchetype, + traits_opt: Option<&PersonalityTraits>, +) -> String { + let stress_label = match ratio { + 0..=30 => "relaxed", + 31..=60 => "tense", + 61..=85 => "stressed", + _ => "near breaking point", + }; + + let mood_label = mood_word(mood); + + match archetype { + CharacterArchetype::Smuggler => { + // Physical threat read + cargo opportunity window + let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) { + "Threat posture. Don't push it." + } else if has_trait(traits_opt, PersonalityTrait::Bold) { + "Confident bearing. Will push back if cornered." + } else if has_trait(traits_opt, PersonalityTrait::Cautious) { + "Nervous type. Predictable under pressure." + } else { + "No obvious threat read." + }; + + let window = if ratio > 60 { + "Too distracted to track cargo movement." + } else if matches!(mood, NpcMood::Focused) { + "Paying close attention to this section." + } else { + "Standard patrol pattern. Window is there." + }; + + format!("Appears {mood_label}, {stress_label}. {threat} {window}") + } + + CharacterArchetype::Detective => { + // Procedural tells + behavioral read + let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) { + "Controlled affect — practiced concealment." + } else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) { + "Involuntary stress markers present." + } else if matches!(mood, NpcMood::Suspicious) { + "Scanning. Aware of being observed." + } else { + "Baseline presentation." + }; + + let read = if ratio > 60 { + "Under pressure — potential liability or asset." + } else if matches!(mood, NpcMood::Content | NpcMood::Warm) { + "Comfortable. Less guarded than usual." + } else { + "Routine behavior pattern." + }; + + format!("Subject: {mood_label}, {stress_label}. {tell} {read}") + } + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// Process examine interaction: generate character-filtered observation text, +/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer. +/// +/// Handles two target types: +/// - NPC entities: generate character-filtered text from NPC component state. +/// - Non-NPC entities with `ExamineText`: use the authored text directly. +/// - Non-NPC entities without `ExamineText`: generic fallback text. +/// +/// System ordering: after process_player_input, before compute_observer_snapshot. +#[allow(clippy::type_complexity)] +pub fn process_examine_interaction( + mut commands: Commands, + time: Res<SimulationTime>, + registry: Res<EntityRegistry>, + mut kg_events: ResMut<KnowledgeEventQueue>, + mut player_query: Query< + ( + Entity, + &TilePosition, + &ExamineRequest, + Option<&CharacterArchetype>, + &mut ExamineResultBuffer, + ), + With<PlayerCharacter>, + >, + npc_query: Query< + ( + &TilePosition, + Option<&MoodState>, + Option<&ToleranceThreshold>, + Option<&PersonalityTraits>, + ), + Without<ObjectType>, + >, + examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>, +) { + let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) = + player_query.single_mut() + else { + return; + }; + + let target = examine_req.target; + let archetype = archetype_opt.copied().unwrap_or_default(); + + // Try NPC examine path first + if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) { + let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX); + if distance > CLOSE_RANGE { + tracing::info!(distance, "Examine: NPC target out of range (max {})", CLOSE_RANGE); + commands.entity(player_entity).remove::<ExamineRequest>(); + return; + } + + let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral); + let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0); + let text = generate_examine_text(mood, ratio, archetype, traits_opt); + + kg_events.push(KnowledgeEvent { + observer: player_entity, + tick: time.tick, + event_type: KnowledgeEventType::DirectObservation { + target, + position: *target_pos, + }, + }); + + let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| { + tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits"); + target.to_bits() + }); + + result_buffer.result = Some(ExamineResultEvent { text, target_entity_id }); + tracing::debug!(target_entity_id, "Examine: NPC result written to buffer"); + commands.entity(player_entity).remove::<ExamineRequest>(); + return; + } + + // Object examine path: entity has a TilePosition but no NPC mood components. + if let Ok((target_pos, examine_text_opt)) = examine_text_query.get(target) { + let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX); + if distance > CLOSE_RANGE { + tracing::info!(distance, "Examine: object target out of range (max {})", CLOSE_RANGE); + commands.entity(player_entity).remove::<ExamineRequest>(); + return; + } + + let text = examine_text_opt + .map(|et| et.0.clone()) + .unwrap_or_else(|| "No further details are apparent.".to_string()); + + let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| { + tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits"); + target.to_bits() + }); + + result_buffer.result = Some(ExamineResultEvent { text, target_entity_id }); + tracing::debug!(target_entity_id, "Examine: object result written to buffer"); + commands.entity(player_entity).remove::<ExamineRequest>(); + return; + } + + tracing::warn!(?target, "process_examine_interaction: target has no position component"); + commands.entity(player_entity).remove::<ExamineRequest>(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::npc::PersonalityTrait; + + fn traits(t: &[PersonalityTrait]) -> PersonalityTraits { + PersonalityTraits { traits: t.to_vec() } + } + + #[test] + fn smuggler_hostile_npc_gives_threat_read() { + let text = generate_examine_text( + NpcMood::Hostile, + 20, + CharacterArchetype::Smuggler, + None, + ); + assert!(text.contains("Threat posture"), "expected threat read, got: {text}"); + } + + #[test] + fn smuggler_focused_npc_notes_attention() { + let text = generate_examine_text( + NpcMood::Focused, + 30, + CharacterArchetype::Smuggler, + None, + ); + assert!(text.contains("close attention"), "expected attention note, got: {text}"); + } + + #[test] + fn smuggler_high_stress_identifies_distraction() { + let text = generate_examine_text( + NpcMood::Anxious, + 80, + CharacterArchetype::Smuggler, + None, + ); + assert!(text.contains("Too distracted"), "expected distraction read, got: {text}"); + } + + #[test] + fn detective_deceptive_npc_notes_concealment() { + let t = traits(&[PersonalityTrait::Deceptive]); + let text = generate_examine_text( + NpcMood::Neutral, + 20, + CharacterArchetype::Detective, + Some(&t), + ); + assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}"); + } + + #[test] + fn detective_anxious_npc_notes_stress_markers() { + let text = generate_examine_text( + NpcMood::Anxious, + 50, + CharacterArchetype::Detective, + None, + ); + assert!( + text.contains("stress markers"), + "expected stress markers, got: {text}" + ); + } + + #[test] + fn detective_content_npc_notes_low_guard() { + let text = generate_examine_text( + NpcMood::Content, + 10, + CharacterArchetype::Detective, + None, + ); + assert!( + text.contains("Less guarded"), + "expected low guard note, got: {text}" + ); + } + + #[test] + fn stress_ratio_zero_when_threshold_zero() { + let t = ToleranceThreshold { current_stress: 50, threshold: 0 }; + assert_eq!(stress_ratio(&t), 0); + } + + #[test] + fn stress_ratio_clamped_at_100() { + let t = ToleranceThreshold { current_stress: 200, threshold: 100 }; + assert_eq!(stress_ratio(&t), 100); + } + + #[test] + fn stress_ratio_negative_stress_is_zero() { + let t = ToleranceThreshold { current_stress: -10, threshold: 70 }; + assert_eq!(stress_ratio(&t), 0); + } + + #[test] + fn examine_result_buffer_take_drains() { + let mut buf = ExamineResultBuffer::default(); + assert!(buf.take().is_none()); + buf.result = Some(ExamineResultEvent { + text: "Test".into(), + target_entity_id: 42, + }); + assert!(buf.take().is_some()); + assert!(buf.take().is_none()); // idempotent drain + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 91457a830..789991789 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -2,13 +2,15 @@ // Timestamped player input events for deterministic simulation (D-010 principle 4) // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) -use crate::bridge::types::{FacingDirection, PlayerAction, PlayerInput}; +use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput}; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; +use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest}; use crate::simulation::inventory::{ find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS, }; use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition}; +use crate::simulation::save_io::{SaveLoadCommand, SaveLoadPending}; use crate::simulation::stance::{PlayerMoveCooldown, Stance}; use crate::simulation::time::{SimulationTime, TickRate}; use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots}; @@ -74,7 +76,8 @@ impl InputQueue { } /// Drains InputQueue for the current tick, converts PlayerActions to ECS components. -/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424). +/// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424), +/// and save/load commands (#553). #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn process_player_input( mut input_queue: ResMut<InputQueue>, @@ -94,6 +97,9 @@ pub fn process_player_input( all_positions: Query<&TilePosition>, reset_triggers: Query<&RoomResetTrigger>, mut room_snapshots: Option<ResMut<RoomSnapshots>>, + mut save_load: Option<ResMut<SaveLoadPending>>, + door_states: Query<&DoorState>, + object_types: Query<&ObjectType>, ) { let current_tick = time.tick; let paused = time.paused(); @@ -104,12 +110,15 @@ pub fn process_player_input( for input in inputs { // Discard all gameplay actions while paused (D-052, R2-OQ-01). - // Only Pause/Unpause/TeleportToHub are processed — everything else is discarded. - // TeleportToHub is exempted because it's a Gauntlet QA action (#491). + // SaveGame/LoadGame are also exempted — saving while paused is valid (#553). if paused && !matches!( input.action, - PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub + PlayerAction::Pause + | PlayerAction::Unpause + | PlayerAction::TeleportToHub + | PlayerAction::SaveGame { .. } + | PlayerAction::LoadGame { .. } ) { continue; @@ -221,6 +230,16 @@ pub fn process_player_input( current_tick, ); } + Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object") + | Some("ExamineObject") | Some("Observe") => { + handle_examine( + &mut commands, + ®istry, + &player_query, + &all_positions, + target_entity_id, + ); + } Some("Confront") => { handle_confront( &mut commands, @@ -240,6 +259,25 @@ pub fn process_player_input( current_tick, ); } + // #246: Door and Terminal behavior + Some("Open") | Some("Close") => { + handle_door_interact( + &mut commands, + ®istry, + &player_query, + &door_states, + target_entity_id, + ); + } + Some("Use") => { + handle_terminal_interact( + &mut commands, + ®istry, + &player_query, + &object_types, + target_entity_id, + ); + } _ => { tracing::info!( "Interact: target={:?}, verb={:?} — logged only", @@ -296,6 +334,36 @@ pub fn process_player_input( PlayerAction::UsePerceptionMode(ref mode) => { tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode); } + PlayerAction::SaveGame { ref path } => { + if let Some(ref mut sl) = save_load { + if sl.pending.is_some() { + tracing::warn!( + "SaveGame overwrites already-pending save/load command (dropped)" + ); + } + sl.pending = Some(SaveLoadCommand::Save { + path: std::path::PathBuf::from(path), + }); + tracing::info!("SaveGame queued: {:?}", path); + } else { + tracing::warn!("SaveGame received but SaveLoadPending resource not registered"); + } + } + PlayerAction::LoadGame { ref path } => { + if let Some(ref mut sl) = save_load { + if sl.pending.is_some() { + tracing::warn!( + "LoadGame overwrites already-pending save/load command (dropped)" + ); + } + sl.pending = Some(SaveLoadCommand::Load { + path: std::path::PathBuf::from(path), + }); + tracing::info!("LoadGame queued: {:?}", path); + } else { + tracing::warn!("LoadGame received but SaveLoadPending resource not registered"); + } + } } } @@ -471,6 +539,65 @@ fn handle_talk( tracing::debug!(target_id, "Talk: TalkRequest marker set on player"); } +/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242). +/// Examine is available at close range (same as Talk). Range check here matches +/// the server-side guard in process_examine_interaction. +#[allow(clippy::type_complexity)] +fn handle_examine( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut crate::simulation::stance::Stance>, + Option<&mut crate::simulation::stance::PlayerMoveCooldown>, + ), + With<PlayerCharacter>, + >, + all_positions: &Query<&TilePosition>, + target_entity_id: Option<u64>, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Examine verb without target_entity_id"); + return; + }; + + let Ok((player_entity, player_pos, _, _)) = player_query.single() else { + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Examine: target entity not in registry"); + return; + }; + + // Server-side range check: reject Examine if target is beyond close range + if let Ok(target_pos) = all_positions.get(target_entity) { + let distance = player_pos + .manhattan_distance(target_pos) + .unwrap_or(u32::MAX); + if distance > crate::simulation::interaction::CLOSE_RANGE { + tracing::info!( + target_id, + distance, + "Examine: target out of range (max {})", + crate::simulation::interaction::CLOSE_RANGE, + ); + return; + } + } + + commands + .entity(player_entity) + .insert(crate::simulation::examine::ExamineRequest { + target: target_entity, + }); + + tracing::debug!(target_id, "Examine: ExamineRequest marker set on player"); +} + /// Handle DialogueResponse action: set DialogueResponseRequest marker (#539). /// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs). /// @@ -767,6 +894,101 @@ fn handle_reset( ); } +/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246). +/// +/// The actual walkability toggle is done by `process_door_interaction` which +/// reads the request and modifies `WalkabilityMap`. The split keeps system +/// ordering explicit and avoids mutable resource conflicts in one system. +fn handle_door_interact( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With<PlayerCharacter>, + >, + door_states: &Query<&DoorState>, + target_entity_id: Option<u64>, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Door verb without target_entity_id"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Door interact: target entity not in registry"); + return; + }; + + // Verify target has DoorState before inserting request + if door_states.get(target_entity).is_err() { + tracing::warn!(target_id, "Door verb on entity without DoorState — ignored"); + return; + } + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + commands + .entity(player_entity) + .insert(DoorInteractRequest { door_entity: target_entity }); + + tracing::debug!(target_id, "Door interact: DoorInteractRequest inserted on player"); +} + +/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246). +fn handle_terminal_interact( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &Query< + ( + Entity, + &TilePosition, + Option<&mut Stance>, + Option<&mut PlayerMoveCooldown>, + ), + With<PlayerCharacter>, + >, + object_types: &Query<&ObjectType>, + target_entity_id: Option<u64>, +) { + let Some(target_id) = target_entity_id else { + tracing::warn!("Use verb without target_entity_id"); + return; + }; + + let target_stable = StableId(target_id); + let Some(target_entity) = registry.to_entity(&target_stable) else { + tracing::warn!(target_id, "Terminal interact: target entity not in registry"); + return; + }; + + // Verify target is a Terminal + match object_types.get(target_entity) { + Ok(ObjectType::Terminal) => {} + _ => { + tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored"); + return; + } + } + + let Ok((player_entity, _, _, _)) = player_query.single() else { + return; + }; + + commands + .entity(player_entity) + .insert(TerminalInteractRequest { terminal_entity: target_entity }); + + tracing::debug!(target_id, "Terminal interact: TerminalInteractRequest inserted on player"); +} + /// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491). /// /// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 7e1337d6d..6cff96115 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -1,6 +1,7 @@ // Interaction system — proximity detection + multi-verb InteractionOptions // Implements #404: server-side verb computation for context-sensitive [E] key // Extended by #421: ObjectType component + verb sets per type (D-057) +// Extended by #246: door toggle, terminal event, examine-text (#246) // Spec: docs/design/interaction-verbs-v0.1.md // D-060: actions[] renamed to verbs[] across all surfaces // @@ -10,14 +11,17 @@ // Phase 2 filtering (KG-gated verbs) handled by #422. use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; // Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422). pub use crate::bridge::types::ObjectType; use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption}; use crate::knowledge::EntityRegistry; +use crate::knowledge::types::StableId; use crate::npc::Npc; -use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; use crate::simulation::stance::Stance; +use crate::simulation::time::SimulationTime; /// Interaction range thresholds (Manhattan distance, same z-level) pub(crate) const CLOSE_RANGE: u32 = 2; @@ -317,6 +321,159 @@ impl NearbyInteractionBuffer { } } +// =========================================================================== +// #246 — Door behavior (toggle walkability) +// =========================================================================== + +/// Component tracking the open/closed state of a door and its blocking tile. +/// +/// Attach to any entity with `ObjectType::Door`. The `blocking_tile` is the +/// tile that becomes walkable when the door opens and impassable when it closes. +/// +/// Door state is persisted in `SaveStateV1.open_doors` (D-010). +#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DoorState { + /// Whether the door is currently open (walkable) or closed (blocking). + pub is_open: bool, + /// The tile whose walkability is toggled by this door. + pub blocking_tile: TilePosition, +} + +impl DoorState { + pub fn new(blocking_tile: TilePosition) -> Self { + DoorState { + is_open: false, + blocking_tile, + } + } +} + +/// Marker: player requested a door interaction (Open or Close) this tick. +/// +/// Inserted by `process_player_input` when verb is "Open" or "Close" on a +/// Door entity. Consumed and removed by `process_door_interaction`. +#[derive(Component, Debug)] +pub struct DoorInteractRequest { + /// The ECS entity of the door to toggle. + pub door_entity: Entity, +} + +/// Event emitted when a player uses a Terminal (#246). +/// +/// Downstream systems (dialogue hook — future work) subscribe to this queue. +/// The queue is not automatically drained — consumers must call `drain()`. +#[derive(Debug, Clone)] +pub struct TerminalInteracted { + /// Stable ID of the terminal entity. + pub terminal_id: StableId, + /// Tick when the interaction occurred. + pub tick: u64, +} + +/// Resource: queue of terminal interaction events (#246). +#[derive(Resource, Default)] +pub struct TerminalInteractedQueue { + pub events: Vec<TerminalInteracted>, +} + +impl TerminalInteractedQueue { + pub fn push(&mut self, event: TerminalInteracted) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec<TerminalInteracted> { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Marker: player requested a Terminal interaction this tick. +/// +/// Inserted by `process_player_input` when verb is "Use" on a Terminal entity. +/// Consumed and removed by `process_terminal_interaction`. +#[derive(Component, Debug)] +pub struct TerminalInteractRequest { + pub terminal_entity: Entity, +} + +/// System: toggle door open/closed state and update walkability map (#246). +/// +/// Reads `DoorInteractRequest` on the player entity. Toggles `DoorState.is_open` +/// and updates `WalkabilityMap` for the door's `blocking_tile`. +/// +/// Ordering: after `process_player_input`, before movement validation. +pub fn process_door_interaction( + mut commands: Commands, + walkability: Option<ResMut<WalkabilityMap>>, + player_query: Query<(Entity, &DoorInteractRequest), With<PlayerCharacter>>, + mut door_query: Query<&mut DoorState>, +) { + let Ok((player_entity, req)) = player_query.single() else { + return; + }; + + let door_entity = req.door_entity; + commands.entity(player_entity).remove::<DoorInteractRequest>(); + + let Ok(mut door) = door_query.get_mut(door_entity) else { + tracing::warn!(?door_entity, "process_door_interaction: no DoorState on target"); + return; + }; + + // Toggle state + door.is_open = !door.is_open; + let walkable = door.is_open; + let tile = door.blocking_tile; + + if let Some(mut walkability) = walkability { + walkability.set_walkable(&tile, walkable); + } + + tracing::info!( + ?tile, + is_open = door.is_open, + "Door toggled: tile walkability set to {walkable}" + ); +} + +/// System: emit TerminalInteracted event when player uses a Terminal (#246). +/// +/// Reads `TerminalInteractRequest` on the player entity, emits to +/// `TerminalInteractedQueue`, and removes the request. +pub fn process_terminal_interaction( + mut commands: Commands, + time: Res<SimulationTime>, + registry: Res<EntityRegistry>, + player_query: Query<(Entity, &TerminalInteractRequest), With<PlayerCharacter>>, + mut queue: ResMut<TerminalInteractedQueue>, +) { + let Ok((player_entity, req)) = player_query.single() else { + return; + }; + + let terminal_entity = req.terminal_entity; + commands.entity(player_entity).remove::<TerminalInteractRequest>(); + + let terminal_id = registry + .to_stable(terminal_entity) + .unwrap_or(StableId(terminal_entity.to_bits())); + + queue.push(TerminalInteracted { + terminal_id, + tick: time.tick, + }); + + tracing::info!( + ?terminal_entity, + terminal_id = terminal_id.0, + tick = time.tick, + "Terminal used: TerminalInteracted event queued" + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 1ca9cc809..f0d6958d5 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod contraband; pub mod conversation; pub mod dialogue; +pub mod examine; pub mod follow; pub mod input; pub mod interaction; @@ -19,7 +20,10 @@ pub mod path_follow; pub mod pathfinding; pub mod poi; pub mod poi_discovery; +pub mod pressure; pub mod rng; +pub mod save_io; +pub mod save_state; pub mod sound; pub mod spatial; pub mod stance; @@ -40,12 +44,16 @@ impl Plugin for SimulationPlugin { app.init_resource::<time::SimulationTime>() .insert_resource(rng::SimRng::new(0)) .init_resource::<input::InputQueue>() + .init_resource::<save_io::SaveLoadPending>() .init_resource::<crate::knowledge::EntityRegistry>() .init_resource::<sound::SoundEventQueue>() .init_resource::<spatial::NaiveSpatialIndex>() .init_resource::<follow::FollowEndEventQueue>() .init_resource::<monologue::PostConversationQueue>() .init_resource::<poi_discovery::PoiDiscoveryEventQueue>() + // Triangle escalation resources (#250) + .init_resource::<crate::content::template::TriangleCrisisEventQueue>() + .init_resource::<crate::content::template::ResolveTriangleQueue>() // discover_pois reads VisibilityGeometry (also populated by PerceptionPlugin). // Init here so SimulationPlugin works standalone in tests without PerceptionPlugin. .init_resource::<crate::perception::query::VisibilityGeometry>() @@ -54,10 +62,17 @@ impl Plugin for SimulationPlugin { // Init here so SimulationPlugin works standalone in tests without those plugins. .init_resource::<crate::npc::relationships::RelationshipGraph>() .init_resource::<crate::knowledge::KnowledgeEventQueue>() + .init_resource::<interaction::TerminalInteractedQueue>() .add_systems( Update, ( input::process_player_input, + // execute_save_load is an exclusive system (takes &mut World). + // Must run after process_player_input (which queues the command) + // and before compute_observer_snapshot (which consumes the result). + save_io::execute_save_load + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), pathfinding::compute_paths.after(input::process_player_input), path_follow::follow_paths.after(pathfinding::compute_paths), movement::validate_movement.after(path_follow::follow_paths), @@ -78,6 +93,27 @@ impl Plugin for SimulationPlugin { poi_discovery::discover_pois .after(crate::perception::observer::compute_visibility_geometry) .before(crate::perception::observer::compute_observer_snapshot), + interaction::process_door_interaction + .after(input::process_player_input) + .before(movement::validate_movement), + interaction::process_terminal_interaction + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), + examine::process_examine_interaction + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), + // Character pressure (#248) — reads NPC awareness + relationship graph + pressure::update_character_pressure + .after(crate::npc::awareness::detect_player_awareness) + .before(crate::perception::observer::compute_observer_snapshot), + // Triangle escalation (#250) — runs on game-minute boundaries (every 10 ticks) + crate::content::template::tick_triangle_escalation + .after(crate::npc::tolerance::check_tolerance_threshold) + .before(crate::perception::observer::compute_observer_snapshot), + // Triangle resolution (#250, D-089) — apply player resolve commands + crate::content::template::apply_resolve_triangle + .after(input::process_player_input) + .before(crate::perception::observer::compute_observer_snapshot), time::advance_tick.after(path_follow::cleanup_path_blocked), ), ); diff --git a/server/src/simulation/pressure.rs b/server/src/simulation/pressure.rs new file mode 100644 index 000000000..2264aca21 --- /dev/null +++ b/server/src/simulation/pressure.rs @@ -0,0 +1,600 @@ +//! Character goal/pressure framework (#248). +//! +//! Defines systemic pressures on the player character that modulate monologue +//! salience and observation priority. Not scripted arcs — emergent from +//! interaction of existing D-024 axes. +//! +//! ## Three pressure axes +//! +//! - **Exposure**: rises when NPCs notice the player (#244 PlayerAwareness) +//! - **Relationship**: rises when NPCs distrust the player (negative trust in +//! `RelationshipGraph`) +//! - **Institutional**: detective-specific pressure (stub in v0.1) +//! +//! ## Update frequency +//! +//! Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute) to avoid +//! per-tick overhead of relationship graph scans. +//! +//! ## Output surface +//! +//! - `CharacterPressureWire` in `ObserverSnapshot` for client HUD +//! - `pressure_mood()` helper for monologue salience weighting — high pressure +//! biases toward anxiety-tagged lines (D-035 mood tags) +//! +//! All arithmetic is integer-only (D-010 determinism). + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::npc::awareness::PlayerAwareness; +use crate::npc::Npc; +use crate::simulation::movement::PlayerCharacter; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::SimulationTime; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Ticks between pressure recalculations. 10 ticks = 1 game-minute (D-031). +pub const PRESSURE_UPDATE_INTERVAL: u64 = 10; + +/// Exposure pressure per suspicious NPC. Scaled so ~5 suspicious NPCs ≈ 50 pressure. +pub const EXPOSURE_PER_SUSPICIOUS_NPC: i32 = 10; + +/// Relationship pressure per hostile edge (trust ≤ -3). Scaled so ~4 hostile NPCs ≈ 60 pressure. +pub const RELATIONSHIP_PER_HOSTILE_EDGE: i32 = 15; + +/// Pressure threshold above which monologue mood shifts to "anxious". +pub const MOOD_ANXIOUS_THRESHOLD: i32 = 50; + +/// Pressure threshold above which monologue mood shifts to "frustrated". +/// Below anxious threshold but above this → frustrated. +pub const MOOD_FRUSTRATED_THRESHOLD: i32 = 30; + +/// Trust value at or below which a relationship counts as "hostile" for +/// relationship pressure. +pub const HOSTILE_TRUST_THRESHOLD: i8 = -3; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/// Systemic pressure on the player character (#248). +/// +/// Attached to the player entity. Updated every game-minute by +/// `update_character_pressure`. Feeds into monologue salience weighting +/// and ObserverSnapshot HUD data. +/// +/// All values are 0–100, integer for D-010 determinism. +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct CharacterPressure { + /// Exposure pressure: rises when NPCs notice the player watching them. + /// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs. + pub exposure: i32, + /// Institutional pressure: detective-specific systemic pressure. + /// Stub in v0.1 — future sprints wire this to detective interaction patterns. + pub institutional: i32, + /// Relationship pressure: rises when NPCs distrust the player. + /// Derived from negative trust edges in the `RelationshipGraph`. + pub relationship: i32, +} + +impl CharacterPressure { + /// Total pressure as a simple average of all axes (0–100). + /// + /// Uses integer division — remainders are floor-truncated (D-010, no floats). + /// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67). + pub fn total(&self) -> i32 { + // Simple average, clamped. Integer division truncates toward zero (D-010). + ((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100) + } + + /// Dominant mood tag for monologue salience weighting. + /// + /// Returns the D-035 mood tag that should be preferred when selecting + /// monologue lines. `None` when pressure is low — baseline monologue + /// selection applies. + pub fn pressure_mood(&self) -> Option<&'static str> { + let total = self.total(); + if total >= MOOD_ANXIOUS_THRESHOLD { + Some("anxious") + } else if total >= MOOD_FRUSTRATED_THRESHOLD { + Some("frustrated") + } else { + None + } + } +} + +// --------------------------------------------------------------------------- +// Wire type for ObserverSnapshot +// --------------------------------------------------------------------------- + +/// Character pressure data for client HUD display (#248). +/// +/// Included in `ObserverSnapshot` when pressure is non-zero. +/// Client renders as a tension/pressure indicator widget. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharacterPressureWire { + /// Exposure pressure (0–100). + pub exposure: i32, + /// Institutional pressure (0–100). + pub institutional: i32, + /// Relationship pressure (0–100). + pub relationship: i32, + /// Total pressure (0–100). + pub total: i32, + /// Dominant mood tag, if any. + pub mood: Option<String>, +} + +impl From<&CharacterPressure> for CharacterPressureWire { + fn from(p: &CharacterPressure) -> Self { + Self { + exposure: p.exposure, + institutional: p.institutional, + relationship: p.relationship, + total: p.total(), + mood: p.pressure_mood().map(String::from), + } + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +/// Update character pressure from NPC awareness and relationship state. +/// +/// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute). +/// +/// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by +/// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100 +/// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the +/// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE` +/// - **Institutional**: stub (0) in v0.1 +pub fn update_character_pressure( + time: Res<SimulationTime>, + awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>, + relationship_graph: Res<crate::npc::relationships::RelationshipGraph>, + registry: Res<crate::knowledge::EntityRegistry>, + mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>, +) { + // Only run on interval ticks + if time.tick % PRESSURE_UPDATE_INTERVAL != 0 { + return; + } + + let Ok((player_entity, mut pressure)) = player_query.single_mut() else { + return; + }; + + // --- Exposure pressure --- + let suspicious_count = awareness_query + .iter() + .filter(|a| a.suspicion_level > 0) + .count() as i32; + pressure.exposure = (suspicious_count * EXPOSURE_PER_SUSPICIOUS_NPC).min(100); + + // --- Relationship pressure --- + let player_stable = registry.to_stable(player_entity); + if let Some(player_sid) = player_stable { + // O(N) over all relationship edges — called once per game-minute, not every tick. + // Acceptable at v0.1 NPC counts (<100 NPCs = <100 edge iterations). + let hostile_edges = relationship_graph + .who_knows_full_scan(&player_sid) + .iter() + .filter(|(_, edge)| edge.trust <= HOSTILE_TRUST_THRESHOLD) + .count() as i32; + pressure.relationship = (hostile_edges * RELATIONSHIP_PER_HOSTILE_EDGE).min(100); + } + + // --- Institutional pressure --- + // Stub: v0.1 has no detective-specific interaction patterns yet. + // Future sprints wire this to game-time progression, investigation progress, + // and institutional NPC interactions. + // pressure.institutional stays at whatever it was (default 0). +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::EntityRegistry; + use crate::npc::awareness::PlayerAwareness; + use crate::npc::relationships::{RelationshipEdge, RelationshipGraph}; + use crate::npc::{Npc, RelationshipKind}; + use crate::simulation::movement::PlayerCharacter; + use crate::simulation::time::SimulationTime; + use crate::simulation::tier::ActiveSim; + use bevy_ecs::world::World; + + fn setup_world() -> World { + let mut world = World::new(); + world.init_resource::<SimulationTime>(); + world.init_resource::<RelationshipGraph>(); + world.init_resource::<EntityRegistry>(); + world + } + + fn run_system(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(update_character_pressure); + schedule.run(world); + } + + // ----------------------------------------------------------------------- + // Update interval gating + // ----------------------------------------------------------------------- + + #[test] + fn skips_non_interval_ticks() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 13; // not on interval + + world.spawn(( + Npc, + ActiveSim, + PlayerAwareness { + suspicion_level: 50, + ..Default::default() + }, + )); + world.spawn((PlayerCharacter, CharacterPressure::default())); + + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!(pressure.exposure, 0, "should not update on non-interval tick"); + } + + #[test] + fn runs_on_interval_tick() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; // on interval + + world.spawn(( + Npc, + ActiveSim, + PlayerAwareness { + suspicion_level: 50, + ..Default::default() + }, + )); + world.spawn((PlayerCharacter, CharacterPressure::default())); + + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!( + pressure.exposure, EXPOSURE_PER_SUSPICIOUS_NPC, + "should update on interval tick" + ); + } + + // ----------------------------------------------------------------------- + // Exposure pressure + // ----------------------------------------------------------------------- + + #[test] + fn exposure_from_suspicious_npcs() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + // 3 suspicious NPCs + for _ in 0..3 { + world.spawn(( + Npc, + ActiveSim, + PlayerAwareness { + suspicion_level: 10, + ..Default::default() + }, + )); + } + // 2 non-suspicious NPCs + for _ in 0..2 { + world.spawn(( + Npc, + ActiveSim, + PlayerAwareness::default(), + )); + } + + world.spawn((PlayerCharacter, CharacterPressure::default())); + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!( + pressure.exposure, + 3 * EXPOSURE_PER_SUSPICIOUS_NPC, + "3 suspicious NPCs × {} per NPC", + EXPOSURE_PER_SUSPICIOUS_NPC + ); + } + + #[test] + fn exposure_caps_at_100() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + // 20 suspicious NPCs — would be 200, should cap at 100 + for _ in 0..20 { + world.spawn(( + Npc, + ActiveSim, + PlayerAwareness { + suspicion_level: 50, + ..Default::default() + }, + )); + } + + world.spawn((PlayerCharacter, CharacterPressure::default())); + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!(pressure.exposure, 100, "exposure should cap at 100"); + } + + #[test] + fn no_suspicious_npcs_zero_exposure() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + world.spawn((Npc, ActiveSim, PlayerAwareness::default())); + world.spawn((PlayerCharacter, CharacterPressure::default())); + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!(pressure.exposure, 0); + } + + // ----------------------------------------------------------------------- + // Relationship pressure + // ----------------------------------------------------------------------- + + #[test] + fn relationship_pressure_from_hostile_edges() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + let mut registry = EntityRegistry::new(0); + let player = world + .spawn((PlayerCharacter, CharacterPressure::default())) + .id(); + let player_sid = registry.register(player); + + // Two NPCs with hostile trust toward the player + let npc1 = world.spawn(Npc).id(); + let npc1_sid = registry.register(npc1); + let npc2 = world.spawn(Npc).id(); + let npc2_sid = registry.register(npc2); + + let mut graph = RelationshipGraph::new(); + graph.set_relationship( + npc1_sid, + player_sid, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: -5, // hostile + history: vec![], + last_interaction_tick: 0, + }, + ); + graph.set_relationship( + npc2_sid, + player_sid, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: -4, // hostile + history: vec![], + last_interaction_tick: 0, + }, + ); + + world.insert_resource(registry); + world.insert_resource(graph); + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!( + pressure.relationship, + 2 * RELATIONSHIP_PER_HOSTILE_EDGE, + "2 hostile edges × {} per edge", + RELATIONSHIP_PER_HOSTILE_EDGE + ); + } + + #[test] + fn neutral_trust_no_relationship_pressure() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + let mut registry = EntityRegistry::new(0); + let player = world + .spawn((PlayerCharacter, CharacterPressure::default())) + .id(); + let player_sid = registry.register(player); + + let npc = world.spawn(Npc).id(); + let npc_sid = registry.register(npc); + + let mut graph = RelationshipGraph::new(); + graph.set_relationship( + npc_sid, + player_sid, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: 0, // neutral + history: vec![], + last_interaction_tick: 0, + }, + ); + + world.insert_resource(registry); + world.insert_resource(graph); + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!(pressure.relationship, 0); + } + + #[test] + fn trust_at_boundary_not_hostile() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + let mut registry = EntityRegistry::new(0); + let player = world + .spawn((PlayerCharacter, CharacterPressure::default())) + .id(); + let player_sid = registry.register(player); + + let npc = world.spawn(Npc).id(); + let npc_sid = registry.register(npc); + + let mut graph = RelationshipGraph::new(); + graph.set_relationship( + npc_sid, + player_sid, + RelationshipEdge { + kind: RelationshipKind::Colleague, + trust: -2, // above hostile threshold (-3) + history: vec![], + last_interaction_tick: 0, + }, + ); + + world.insert_resource(registry); + world.insert_resource(graph); + run_system(&mut world); + + let mut q = world.query::<&CharacterPressure>(); + let pressure = q.single(&world).unwrap(); + assert_eq!( + pressure.relationship, 0, + "trust -2 should not count as hostile (threshold is -3)" + ); + } + + // ----------------------------------------------------------------------- + // Total + mood + // ----------------------------------------------------------------------- + + #[test] + fn total_is_average_clamped() { + let p = CharacterPressure { + exposure: 60, + institutional: 30, + relationship: 45, + }; + // (60 + 30 + 45) / 3 = 45 + assert_eq!(p.total(), 45); + } + + #[test] + fn total_does_not_go_below_zero() { + let p = CharacterPressure { + exposure: 0, + institutional: 0, + relationship: 0, + }; + assert_eq!(p.total(), 0); + } + + #[test] + fn pressure_mood_anxious() { + let p = CharacterPressure { + exposure: 100, + institutional: 100, + relationship: 100, + }; + // total = 100, >= 50 → anxious + assert_eq!(p.pressure_mood(), Some("anxious")); + } + + #[test] + fn pressure_mood_frustrated() { + let p = CharacterPressure { + exposure: 50, + institutional: 50, + relationship: 20, + }; + // total = (50+50+20)/3 = 40, >= 30 but < 50 → frustrated + assert_eq!(p.pressure_mood(), Some("frustrated")); + } + + #[test] + fn pressure_mood_none_when_low() { + let p = CharacterPressure { + exposure: 10, + institutional: 0, + relationship: 10, + }; + // total = (10+0+10)/3 = 6, < 30 → None + assert_eq!(p.pressure_mood(), None); + } + + // ----------------------------------------------------------------------- + // Wire roundtrip + // ----------------------------------------------------------------------- + + #[test] + fn wire_roundtrip() { + let p = CharacterPressure { + exposure: 30, + institutional: 10, + relationship: 50, + }; + let wire = CharacterPressureWire::from(&p); + let json = serde_json::to_string(&wire).expect("should serialize"); + let decoded: CharacterPressureWire = + serde_json::from_str(&json).expect("should deserialize"); + assert_eq!(decoded.exposure, 30); + assert_eq!(decoded.institutional, 10); + assert_eq!(decoded.relationship, 50); + assert_eq!(decoded.total, p.total()); + assert_eq!(decoded.mood, p.pressure_mood().map(String::from)); + } + + // ----------------------------------------------------------------------- + // No player entity — no panic + // ----------------------------------------------------------------------- + + #[test] + fn no_player_no_panic() { + let mut world = setup_world(); + world.resource_mut::<SimulationTime>().tick = 10; + run_system(&mut world); // should not panic + } + + // ----------------------------------------------------------------------- + // Constant value assertions (#248 spec compliance) + // ----------------------------------------------------------------------- + + #[test] + fn pressure_constants_have_expected_values() { + assert_eq!( + PRESSURE_UPDATE_INTERVAL, 10, + "#248: pressure updates every 10 ticks (1 game-minute, D-031)" + ); + assert_eq!(EXPOSURE_PER_SUSPICIOUS_NPC, 10); + assert_eq!(RELATIONSHIP_PER_HOSTILE_EDGE, 15); + assert_eq!(MOOD_ANXIOUS_THRESHOLD, 50); + assert_eq!(MOOD_FRUSTRATED_THRESHOLD, 30); + assert_eq!(HOSTILE_TRUST_THRESHOLD, -3); + } +} diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs new file mode 100644 index 000000000..16519a59f --- /dev/null +++ b/server/src/simulation/save_io.rs @@ -0,0 +1,718 @@ +// Save/load ECS extraction (#553) +// Implements D-020 MessagePack format for save files, D-010 determinism. +// +// Two entry points: +// save_to_file: queries ECS, builds SaveStateV1, writes MessagePack to path. +// load_from_file: reads path, deserialises SaveStateV1, re-injects ECS state. +// +// IPC: SaveGame / LoadGame PlayerAction variants queue commands here. +// execute_save_load: exclusive system that drains the queue and writes the result +// to SnapshotBuffer.pending_save_result for client feedback. + +use std::path::{Path, PathBuf}; + +use bevy_ecs::prelude::*; +use thiserror::Error; + +use crate::bridge::types::SaveLoadResultWire; +use crate::bridge::types::SnapshotBuffer; +use crate::content::template::{TemplateReferenceMap, TriangleState}; +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::registry::EntityRegistry; +use crate::npc::Npc; +use crate::npc::relationships::RelationshipGraph; +use crate::simulation::movement::PlayerCharacter; +use crate::simulation::rng::SimRng; +use crate::simulation::save_state::{ + deserialize_npc_from_frozen, serialize_npc_to_frozen, SaveStateV1, SAVE_FORMAT_VERSION, +}; +use crate::knowledge::types::StableId; +use crate::simulation::interaction::DoorState; +use crate::simulation::tier::BackgroundSim; +use crate::simulation::time::SimulationTime; + +/// Errors from save/load operations (#553). +#[derive(Debug, Error)] +pub enum SaveLoadError { + #[error("I/O error: {0}")] + Io(String), + #[error("serialization error: {0}")] + Serialize(String), + #[error("deserialization error: {0}")] + Deserialize(String), + #[error("format version mismatch: expected {expected}, found {found}")] + VersionMismatch { expected: u8, found: u8 }, +} + +/// A queued save or load command (#553). +#[derive(Debug, Clone)] +pub enum SaveLoadCommand { + Save { path: PathBuf }, + Load { path: PathBuf }, +} + +/// Pending save/load command resource (#553). +/// +/// `process_player_input` queues commands here when it encounters +/// `PlayerAction::SaveGame` or `PlayerAction::LoadGame`. The +/// `execute_save_load` exclusive system drains this queue each tick. +#[derive(Resource, Debug, Default)] +pub struct SaveLoadPending { + /// Pending command (at most one; new commands overwrite pending ones). + pub pending: Option<SaveLoadCommand>, +} + +/// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553). +/// +/// Queries all NPC entities, the player knowledge graph, global relationship graph, +/// simulation time, and RNG seed. Builds `SaveStateV1` and writes to disk. +/// +/// NPC states are sorted by `stable_id` ascending for determinism (D-010). +/// NPCs without `StableEntityId` trigger a panic (caller invariant — all live +/// NPCs must be registered before save). +/// +/// # Errors +/// `SaveLoadError::Io` on filesystem failure. +/// `SaveLoadError::Serialize` on MessagePack encoding failure. +pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> { + // Simulation clock + let (tick, tick_rate) = { + let t = world.resource::<SimulationTime>(); + (t.tick, t.tick_rate) + }; + + // RNG seed for deterministic replay (D-010) + let seed = world.resource::<SimRng>().seed(); + + // Player knowledge graph — the observer's epistemics at save time + let player_knowledge = { + let mut q = world.query_filtered::<&KnowledgeGraph, With<PlayerCharacter>>(); + q.single(world).cloned().unwrap_or_else(|_| { + tracing::warn!("save_to_file: no PlayerCharacter with KnowledgeGraph found — saving empty graph"); + KnowledgeGraph::new() + }) + }; + + // Global NPC social web + let relationship_graph = world.resource::<RelationshipGraph>().clone(); + + // Per-NPC states: collect then sort by stable_id (D-010 determinism) + let npc_entities: Vec<Entity> = { + let mut q = world.query_filtered::<Entity, With<Npc>>(); + q.iter(world).collect() + }; + let mut npc_states: Vec<_> = npc_entities + .iter() + .map(|&entity| serialize_npc_to_frozen(entity, world)) + .collect(); + npc_states.sort_by_key(|s| s.stable_id.0); + + let npc_count = npc_states.len(); + + // Capture TemplateReferenceMap if present — default to empty if not yet initialised. + let template_references = world + .get_resource::<TemplateReferenceMap>() + .cloned() + .unwrap_or_default(); + + // Capture TriangleState components — sorted by triangle_id for determinism (D-010). + let mut triangle_states: Vec<TriangleState> = { + let mut q = world.query::<&TriangleState>(); + q.iter(world).cloned().collect() + }; + triangle_states.sort_by_key(|t| t.triangle_id.0); + + let state = SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick, + tick_rate, + seed, + player_knowledge, + relationship_graph, + npc_states, + template_references, + triangle_states, + open_doors: { + use crate::knowledge::registry::StableEntityId; + let mut q = world.query::<(&DoorState, &StableEntityId)>(); + let mut ids: Vec<_> = q + .iter(world) + .filter(|(ds, _)| ds.is_open) + .map(|(_, sid)| sid.0) + .collect(); + ids.sort_by_key(|id| id.0); + ids + }, + }; + + let bytes = state + .to_bytes() + .map_err(|e| SaveLoadError::Serialize(e.to_string()))?; + + std::fs::write(path, &bytes).map_err(|e| SaveLoadError::Io(e.to_string()))?; + + tracing::info!( + "save_to_file: {:?} (tick={}, npcs={}, {} bytes)", + path, + tick, + npc_count, + bytes.len() + ); + Ok(()) +} + +/// Read `path`, deserialise `SaveStateV1`, and re-inject state into the ECS (#553). +/// +/// Steps: +/// 1. Read and deserialise bytes; reject if `format_version != SAVE_FORMAT_VERSION`. +/// 2. Despawn all existing NPC entities and unregister them from `EntityRegistry`. +/// 3. Re-spawn each NPC via `deserialize_npc_from_frozen`; register with +/// `register_existing`; insert `BackgroundSim` tier marker. +/// 4. Advance `EntityRegistry` counter past all restored IDs. +/// 5. Restore `RelationshipGraph`, `SimulationTime`, and `SimRng` resources. +/// 6. Update the player entity's `KnowledgeGraph` if a player entity exists. +/// +/// **Gotcha (D-010):** Bevy `Entity` handles are generational. `NpcSaveState` uses +/// `StableId(u64)` throughout — `EntityRegistry` maps restored `StableId`s to the +/// new `Entity` handles after re-spawn. +/// +/// # Errors +/// `SaveLoadError::Io` on filesystem failure. +/// `SaveLoadError::Deserialize` on MessagePack decoding failure. +/// `SaveLoadError::VersionMismatch` when the save file predates the current schema. +pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> { + let bytes = std::fs::read(path).map_err(|e| SaveLoadError::Io(e.to_string()))?; + let state = + SaveStateV1::from_bytes(&bytes).map_err(|e| SaveLoadError::Deserialize(e.to_string()))?; + + if state.format_version != SAVE_FORMAT_VERSION { + return Err(SaveLoadError::VersionMismatch { + expected: SAVE_FORMAT_VERSION, + found: state.format_version, + }); + } + + let npc_count = state.npc_states.len(); + + // Despawn all existing NPC entities and clear their registry entries. + let npc_entities: Vec<Entity> = { + let mut q = world.query_filtered::<Entity, With<Npc>>(); + q.iter(world).collect() + }; + for entity in npc_entities { + world.resource_mut::<EntityRegistry>().unregister(entity); + world.despawn(entity); + } + + // Track the highest restored StableId so we can advance the counter. + let mut max_id: u64 = 0; + + // Re-spawn NPCs, assign tier marker, register pre-existing StableIds. + for npc_state in &state.npc_states { + let entity = deserialize_npc_from_frozen(npc_state, world); + + // Loaded NPCs start in BackgroundSim; the distance system promotes as needed. + world.entity_mut(entity).insert(BackgroundSim); + + let stable_id = npc_state.stable_id; + world + .resource_mut::<EntityRegistry>() + .register_existing(entity, stable_id); + + max_id = max_id.max(stable_id.0); + } + + // Advance the registry counter past all restored IDs so future register() + // calls produce non-conflicting IDs. + if npc_count > 0 { + world.resource_mut::<EntityRegistry>().advance_past(max_id); + } + + // Restore simulation resources. + world.insert_resource(state.relationship_graph); + world.insert_resource(state.template_references); + + // Restore triangle states (#250) — spawn dedicated entities for each. + for ts in &state.triangle_states { + world.spawn(ts.clone()); + } + { + let mut t = world.resource_mut::<SimulationTime>(); + t.tick = state.tick; + t.tick_rate = state.tick_rate; + } + world.insert_resource(SimRng::new(state.seed)); + + // Restore door open states (#246) — find door entities by StableId and toggle. + if !state.open_doors.is_empty() { + let open_set: std::collections::HashSet<_> = state.open_doors.iter().copied().collect(); + let door_entities: Vec<(Entity, StableId)> = { + let mut q = world.query::<(Entity, &crate::knowledge::registry::StableEntityId, &DoorState)>(); + q.iter(world) + .filter(|(_, sid, _)| open_set.contains(&sid.0)) + .map(|(e, sid, _)| (e, sid.0)) + .collect() + }; + for (entity, sid) in door_entities { + if let Some(mut door) = world.get_mut::<DoorState>(entity) { + door.is_open = true; + let tile = door.blocking_tile; + if let Some(mut wmap) = world.get_resource_mut::<crate::simulation::movement::WalkabilityMap>() { + wmap.set_walkable(&tile, true); + } + tracing::debug!(stable_id = sid.0, "load: restored open door state"); + } + } + } + + // Update the player entity's KnowledgeGraph if a player exists. + let player_entity = { + let mut q = world.query_filtered::<Entity, With<PlayerCharacter>>(); + q.single(world).ok() + }; + if let Some(player_entity) = player_entity { + world + .entity_mut(player_entity) + .insert(state.player_knowledge); + } + + tracing::info!( + "load_from_file: {:?} (tick={}, npcs={})", + path, + state.tick, + npc_count, + ); + Ok(()) +} + +/// Exclusive system: drain `SaveLoadPending` and execute queued save/load (#553). +/// +/// Runs each tick, after `process_player_input`. If a command is pending, +/// executes it and writes `SaveLoadResultWire` to `SnapshotBuffer.pending_save_result` +/// for consumption by `compute_observer_snapshot` the same tick. +pub fn execute_save_load(world: &mut World) { + // Take the pending command (releases the borrow before we use world again). + let command = { + let mut pending = world.resource_mut::<SaveLoadPending>(); + pending.pending.take() + }; + + let Some(command) = command else { + return; + }; + + let (kind_str, result) = match &command { + SaveLoadCommand::Save { path } => { + let r = save_to_file(path, world); + ("save", r) + } + SaveLoadCommand::Load { path } => { + let r = load_from_file(path, world); + ("load", r) + } + }; + + let wire_result = match result { + Ok(()) => { + tracing::info!("execute_save_load: {} completed", kind_str); + SaveLoadResultWire { + success: true, + kind: kind_str.to_string(), + error: None, + } + } + Err(ref e) => { + tracing::error!("execute_save_load: {} failed: {}", kind_str, e); + SaveLoadResultWire { + success: false, + kind: kind_str.to_string(), + error: Some(e.to_string()), + } + } + }; + + // Write result to SnapshotBuffer for client feedback (one tick only — consumed by + // compute_observer_snapshot via pending_save_result.take()). + if let Some(mut buf) = world.get_resource_mut::<SnapshotBuffer>() { + buf.pending_save_result = Some(wire_result); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::graph::KnowledgeGraph; + use crate::knowledge::registry::{EntityRegistry, StableEntityId}; + use crate::knowledge::types::StableId; + use crate::npc::Npc; + use crate::npc::relationships::RelationshipGraph; + use crate::simulation::movement::TilePosition; + use crate::simulation::rng::SimRng; + use crate::simulation::save_state::{SaveStateV1, SAVE_FORMAT_VERSION}; + use crate::simulation::time::{SimulationTime, TickRate}; + use bevy_ecs::world::World; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_path() -> PathBuf { + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("settled_reach_save_io_test_{}.msgpack", id)) + } + + fn minimal_world() -> World { + let mut w = World::new(); + w.insert_resource(SimulationTime::default()); + w.insert_resource(SimRng::new(42)); + w.insert_resource(RelationshipGraph::new()); + w.init_resource::<EntityRegistry>(); + w + } + + fn spawn_test_npc(world: &mut World, stable_id: u64) -> Entity { + world + .spawn(( + Npc, + StableEntityId(StableId(stable_id)), + TilePosition::new(stable_id as i32, 0, 0), + )) + .id() + } + + // ----------------------------------------------------------------------- + // save_to_file + // ----------------------------------------------------------------------- + + #[test] + fn save_to_file_creates_valid_msgpack() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 1); + spawn_test_npc(&mut world, 2); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save should succeed"); + + let bytes = std::fs::read(&path).expect("file should exist"); + let state = SaveStateV1::from_bytes(&bytes).expect("bytes must be valid msgpack"); + assert_eq!(state.format_version, SAVE_FORMAT_VERSION); + assert_eq!(state.npc_states.len(), 2); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_to_file_sorts_npc_states_by_stable_id() { + let mut world = minimal_world(); + // Spawn in reverse order — save should still sort ascending + spawn_test_npc(&mut world, 50); + spawn_test_npc(&mut world, 10); + spawn_test_npc(&mut world, 30); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save should succeed"); + + let bytes = std::fs::read(&path).expect("read saved file"); + let state = SaveStateV1::from_bytes(&bytes).unwrap(); + let ids: Vec<u64> = state.npc_states.iter().map(|n| n.stable_id.0).collect(); + assert_eq!(ids, vec![10, 30, 50], "npc_states must be sorted by stable_id"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_to_file_preserves_tick_and_seed() { + let mut world = minimal_world(); + { + let mut t = world.resource_mut::<SimulationTime>(); + t.tick = 9999; + t.tick_rate = TickRate::Half; + } + world.insert_resource(SimRng::new(0xDEADBEEF)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + let bytes = std::fs::read(&path).unwrap(); + let state = SaveStateV1::from_bytes(&bytes).unwrap(); + assert_eq!(state.tick, 9999); + assert_eq!(state.tick_rate, TickRate::Half); + assert_eq!(state.seed, 0xDEADBEEF); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn save_to_file_returns_io_error_on_bad_path() { + let mut world = minimal_world(); + let bad_path = std::path::Path::new("/nonexistent/directory/save.msgpack"); + let result = save_to_file(bad_path, &mut world); + assert!( + matches!(result, Err(SaveLoadError::Io(_))), + "expected Io error for bad path" + ); + } + + // ----------------------------------------------------------------------- + // load_from_file + // ----------------------------------------------------------------------- + + #[test] + fn load_from_file_restores_npc_count() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 1); + spawn_test_npc(&mut world, 2); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Spawn an extra NPC — loading should despawn the old NPCs and restore exactly 2 + spawn_test_npc(&mut world, 99); + let pre_load_count = { + let mut q = world.query_filtered::<Entity, With<Npc>>(); + q.iter(&world).count() + }; + assert_eq!(pre_load_count, 3, "three NPCs before load"); + + load_from_file(&path, &mut world).expect("load"); + + let post_load_count = { + let mut q = world.query_filtered::<Entity, With<Npc>>(); + q.iter(&world).count() + }; + assert_eq!(post_load_count, 2, "exactly the two saved NPCs after load"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_restores_stable_ids_in_registry() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 10); + spawn_test_npc(&mut world, 20); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + load_from_file(&path, &mut world).expect("load"); + + let registry = world.resource::<EntityRegistry>(); + assert!( + registry.to_entity(&StableId(10)).is_some(), + "StableId(10) must be in registry after load" + ); + assert!( + registry.to_entity(&StableId(20)).is_some(), + "StableId(20) must be in registry after load" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_restores_tick_and_seed() { + let mut world = minimal_world(); + { + let mut t = world.resource_mut::<SimulationTime>(); + t.tick = 5000; + } + world.insert_resource(SimRng::new(12345)); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + + // Change time and seed, then load + { + let mut t = world.resource_mut::<SimulationTime>(); + t.tick = 1; + } + world.insert_resource(SimRng::new(0)); + + load_from_file(&path, &mut world).expect("load"); + + let t = world.resource::<SimulationTime>(); + assert_eq!(t.tick, 5000, "tick restored from save"); + assert_eq!( + world.resource::<SimRng>().seed(), + 12345, + "seed restored from save" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_rejects_wrong_format_version() { + use crate::content::template::TemplateReferenceMap; + // Craft a save with a wrong format_version + let bad_state = SaveStateV1 { + format_version: 0xFF, // deliberately wrong + tick: 0, + tick_rate: TickRate::Full, + seed: 0, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], + open_doors: vec![], + }; + let bytes = bad_state.to_bytes().expect("serialize"); + let path = temp_path(); + std::fs::write(&path, &bytes).expect("write"); + + let mut world = minimal_world(); + let result = load_from_file(&path, &mut world); + assert!( + matches!(result, Err(SaveLoadError::VersionMismatch { .. })), + "expected VersionMismatch error, got {:?}", + result + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_from_file_returns_io_error_for_missing_file() { + let mut world = minimal_world(); + let missing = std::path::Path::new("/tmp/settled_reach_nonexistent_42.msgpack"); + let result = load_from_file(missing, &mut world); + assert!( + matches!(result, Err(SaveLoadError::Io(_))), + "expected Io error for missing file" + ); + } + + #[test] + fn load_from_file_assigns_background_sim_tier() { + let mut world = minimal_world(); + spawn_test_npc(&mut world, 1); + + let path = temp_path(); + save_to_file(&path, &mut world).expect("save"); + load_from_file(&path, &mut world).expect("load"); + + let has_background: bool = { + let mut q = world.query_filtered::<Entity, (With<Npc>, With<BackgroundSim>)>(); + q.iter(&world).count() > 0 + }; + assert!( + has_background, + "loaded NPC should be in BackgroundSim tier" + ); + + let _ = std::fs::remove_file(&path); + } + + // ----------------------------------------------------------------------- + // execute_save_load + // ----------------------------------------------------------------------- + + #[test] + fn execute_save_load_noop_when_no_pending() { + let mut world = minimal_world(); + world.init_resource::<SaveLoadPending>(); + world.init_resource::<SnapshotBuffer>(); + + execute_save_load(&mut world); + + // No result written when no pending command + let buf = world.resource::<SnapshotBuffer>(); + assert!( + buf.pending_save_result.is_none(), + "no pending_save_result when no command was queued" + ); + } + + #[test] + fn execute_save_load_writes_success_result() { + let mut world = minimal_world(); + world.init_resource::<SnapshotBuffer>(); + + let path = temp_path(); + world.insert_resource(SaveLoadPending { + pending: Some(SaveLoadCommand::Save { path: path.clone() }), + }); + + execute_save_load(&mut world); + + let buf = world.resource::<SnapshotBuffer>(); + let result = buf + .pending_save_result + .as_ref() + .expect("result must be written after execute"); + assert!(result.success, "save should succeed"); + assert_eq!(result.kind, "save"); + assert!(result.error.is_none()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn execute_save_load_writes_error_result_on_bad_path() { + let mut world = minimal_world(); + world.init_resource::<SnapshotBuffer>(); + + world.insert_resource(SaveLoadPending { + pending: Some(SaveLoadCommand::Save { + path: PathBuf::from("/nonexistent/dir/save.msgpack"), + }), + }); + + execute_save_load(&mut world); + + let buf = world.resource::<SnapshotBuffer>(); + let result = buf + .pending_save_result + .as_ref() + .expect("result must be written even on failure"); + assert!(!result.success, "save should fail with bad path"); + assert_eq!(result.kind, "save"); + assert!(result.error.is_some(), "error message should be present"); + } + + // ----------------------------------------------------------------------- + // Overwrite behaviour + // ----------------------------------------------------------------------- + + /// When two commands arrive in the same tick, the second overwrites the first. + /// The warn! in process_player_input fires; here we just confirm last-write-wins. + #[test] + fn pending_command_overwrite_last_write_wins() { + let mut pending = SaveLoadPending::default(); + + pending.pending = Some(SaveLoadCommand::Save { + path: PathBuf::from("/tmp/first.msgpack"), + }); + // Overwrite with a Load command + pending.pending = Some(SaveLoadCommand::Load { + path: PathBuf::from("/tmp/second.msgpack"), + }); + + match pending.pending.unwrap() { + SaveLoadCommand::Load { ref path } => { + assert_eq!(path.to_str().unwrap(), "/tmp/second.msgpack"); + } + other => panic!("expected Load, got {:?}", other), + } + } + + // ----------------------------------------------------------------------- + // SaveLoadError display + // ----------------------------------------------------------------------- + + #[test] + fn save_load_error_display() { + let e = SaveLoadError::Io("disk full".into()); + assert!(e.to_string().contains("disk full")); + + let e2 = SaveLoadError::VersionMismatch { + expected: 1, + found: 2, + }; + assert!(e2.to_string().contains("expected 1")); + assert!(e2.to_string().contains("found 2")); + } +} diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs new file mode 100644 index 000000000..5beddc540 --- /dev/null +++ b/server/src/simulation/save_state.rs @@ -0,0 +1,831 @@ +//! Save state data model (#256, D-010). +//! +//! `SaveStateV1` is the versioned serialization envelope for full game state. +//! Shares architecture with #96 (state serialization system) — this module +//! defines the data model AND the per-NPC serialization primitives for tier +//! eviction freeze/thaw (#96). +//! +//! ## Write format: MessagePack +//! +//! Decision: MessagePack via `rmp_serde` (consistent with IPC protocol, D-020). +//! Both the IPC protocol and save files use the same codec for simplicity. +//! RON/YAML alternatives were considered — MessagePack chosen for consistency. +//! Human-readable debug output can be derived via the Debug impl or a separate +//! conversion step; a full RON bridge is deferred beyond v0.1. +//! +//! ## Versioning strategy +//! +//! `format_version: u8` bumps on breaking schema changes. Loader checks version +//! and rejects incompatible saves. `serde(default)` on optional new fields allows +//! forward-compatible extensions within the same major version. +//! +//! ## What is captured (v0.1 scope) +//! +//! - Simulation clock: `tick` + `tick_rate` for correct time reconstruction +//! - RNG seed: reproduce the same random sequence on load (D-010) +//! - Player knowledge graph: the observer's epistemics at save time +//! - Global relationship graph: the NPC social web (resource, not per-entity) +//! - Per-NPC summary state: the axis values that drive tell/mood/dialogue +//! +//! ## Not yet captured (deferred to #257 and beyond) +//! +//! - Full ECS world extraction/injection (system not yet written) +//! - Pathfinding state (reconstructed from position + routine) +//! - Tier transitions in-flight (dropped to background state on load) +//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after +//! reload; memory degrades naturally over time so reset-on-load is acceptable) + +use bevy_ecs::entity::Entity; +use bevy_ecs::world::World; +use serde::{Deserialize, Serialize}; + +use crate::content::template::{TemplateOwnership, TemplateReferenceMap, TriangleState}; +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::StableId; +use crate::npc::{ + CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc, + PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem, + ToleranceThreshold, Want, WantKind, +}; +use crate::npc::awareness::PlayerAwareness; +use crate::npc::mood::MoodState; +use crate::npc::relationships::RelationshipGraph; +use crate::npc::vision::{NpcMemory, NpcVisionState}; +use crate::simulation::movement::TilePosition; +use crate::simulation::time::TickRate; + +/// Current format version. Bump on any breaking schema change. +pub const SAVE_FORMAT_VERSION: u8 = 1; + +/// Top-level save state envelope (#256, D-010). +/// +/// Serialized with MessagePack (rmp_serde) for storage. Load with +/// `rmp_serde::from_slice::<SaveStateV1>(&bytes)`. +/// +/// Versioned from day one: check `format_version == SAVE_FORMAT_VERSION` +/// before trusting content. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SaveStateV1 { + /// Format version. Must equal `SAVE_FORMAT_VERSION` on load. + pub format_version: u8, + /// Simulation tick at the moment of save. + pub tick: u64, + /// Active tick rate at save time (Full/Half/Paused). + pub tick_rate: TickRate, + /// RNG seed active at save time for deterministic replay (D-010). + /// On load, seed the RNG from this value before advancing any ticks. + pub seed: u64, + /// Player character knowledge graph — the observer's epistemics at save time. + pub player_knowledge: KnowledgeGraph, + /// Global NPC relationship graph resource. + /// Serialized as a unit: all directed edges between NPCs and player. + pub relationship_graph: RelationshipGraph, + /// Per-NPC summary state for each simulated NPC. + /// Order is deterministic (sorted by stable_id in ascending order). + pub npc_states: Vec<NpcSaveState>, + /// Cross-template reference links (#165). + /// Preserved across save/load so that tier-evicted templates retain their + /// relationship metadata even when their NPCs are not in Active tier. + #[serde(default)] + pub template_references: TemplateReferenceMap, + /// Triangle escalation states (#250). + /// Persisted so tension/phase survive save/load. Sorted by triangle_id + /// for deterministic serialization (D-010). + #[serde(default)] + pub triangle_states: Vec<TriangleState>, + /// Stable IDs of doors that are currently open (#246). + /// Doors not in this list are assumed closed on load. Sorted ascending + /// for deterministic serialization (D-010). + #[serde(default)] + pub open_doors: Vec<StableId>, +} + +/// Per-NPC state snapshot for `SaveStateV1`. +/// +/// Two usage contexts: +/// 1. **Whole-game save** (`SaveStateV1.npc_states`): populated by #553 ECS extraction. +/// Only the core axis fields need to be populated for this use case. +/// 2. **Tier eviction freeze** (produced by `serialize_npc_to_frozen`): captures ALL +/// components needed for full NPC reconstruction from `StateSaved` tier. +/// The extended optional fields (#96) carry all 10 D-024 axes. +/// +/// All fields added post-#256 use `#[serde(default)]` for forward compatibility +/// with older save files that predate these fields. +/// +/// ## D-024 axis coverage +/// | Axis | Field | Status | +/// |------|-------|--------| +/// | 1: Want | `want` | Full (optional for backward compat) | +/// | 2: Secret | `secret_severity` (legacy) + `secret` | Full | +/// | 3: Relationships | `relationships` | Full | +/// | 4: Tolerance | `current_stress` + `tolerance_threshold` | Full | +/// | 5: Daily routine | `routine` | Full (optional) | +/// | 6: Information inventory | `information_inventory` | Full (optional) | +/// | 7: Contentment | `contentment` | Full | +/// | Supporting 1: Personality | `personality_traits` | Full (optional) | +/// | Supporting 2: Tells | `tell_system` | Full (optional) | +/// | Supporting 3: Skills | `skill_set` + `combat_capability` | Full (optional) | +/// +/// ## Components intentionally NOT serialized +/// - `NpcVisionState`: runtime LOS state, reset to default on reactivation +/// - `NpcMemory`: stale inferences would be wrong after reload (intentional drop) +/// - `PlayerAwareness`: runtime derived state, reset to default on reactivation +/// - `AnimationTier`: resets to `Tier1` on reactivation (no persistent state) +/// - `RoutineDeviation`: transient event marker, acceptable to drop on reload +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NpcSaveState { + /// Stable entity identifier (survives serialization — D-020). + pub stable_id: StableId, + /// Last known tile position. + pub position: TilePosition, + + // Axis 2: Secret severity (legacy field — description regenerated from content on load). + // Kept for backward compatibility. Prefer `secret` field when doing full reconstruction. + pub secret_severity: SecretSeverity, + // Axis 3: Per-NPC relationship slots + pub relationships: Option<Relationships>, + // Axis 4: Tolerance — current stress level at save time + pub current_stress: i16, + /// Tolerance threshold value (does not change at runtime). + pub tolerance_threshold: i16, + // Axis 7: Contentment + pub contentment: i16, + + /// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG). + pub knowledge_graph: Option<KnowledgeGraph>, + + // --- Full reconstruction fields (added #96, for tier eviction freeze) --- + // All fields below use serde(default) for backward compatibility with saves + // created before #96 shipped. + + /// Axis 1: Want (primary drive, intensity, and description). + #[serde(default)] + pub want: Option<Want>, + + /// Axis 2: Full secret (description + known_by list). + /// Supersedes `secret_severity` for full reconstruction. + #[serde(default)] + pub secret: Option<Secret>, + + /// Axis 5: Daily routine (phase → location schedule). + #[serde(default)] + pub routine: Option<DailyRoutine>, + + /// Axis 6: Information inventory (facts this NPC carries). + #[serde(default)] + pub information_inventory: Option<InformationInventory>, + + /// Supporting axis 1: Personality traits (2–3 traits, no contradictory pairs). + #[serde(default)] + pub personality_traits: Option<PersonalityTraits>, + + /// Supporting axis 2: Tell system (behavioral tells tied to stress/personality). + #[serde(default)] + pub tell_system: Option<TellSystem>, + + /// Supporting axis 3: Skill set (proficiency BTreeMap). + #[serde(default)] + pub skill_set: Option<SkillSet>, + + /// Optional combat capability (only present for combat-trained NPCs). + #[serde(default)] + pub combat_capability: Option<CombatCapability>, + + /// Mood state at save time. Derived from stress but worth preserving across + /// tier transitions to avoid jarring state resets on reactivation. + #[serde(default)] + pub mood_state: Option<MoodState>, + + /// Job performance score — drifts over time, persist across tier transitions. + #[serde(default)] + pub job_performance: Option<JobPerformance>, + + /// Template ownership (#165): which template owns this NPC and which role it fills. + /// `None` for NPCs that predate the template system or were hand-authored without + /// template assignment. Preserved across tier transitions (D-025 single-ownership). + #[serde(default)] + pub template_ownership: Option<TemplateOwnership>, +} + +impl SaveStateV1 { + /// Serialize to MessagePack bytes. + pub fn to_bytes(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> { + rmp_serde::to_vec_named(self) + } + + /// Deserialize from MessagePack bytes. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> { + rmp_serde::from_slice(bytes) + } +} + +// --------------------------------------------------------------------------- +// Per-NPC tier eviction serialization primitives (#96) +// --------------------------------------------------------------------------- + +/// Serialize a live NPC entity to a `NpcSaveState` frozen struct. +/// +/// Used by the tier eviction system when demoting an entity to `StateSaved`: +/// instead of keeping all ECS components live, the entity is frozen and despawned. +/// The caller should despawn the entity after calling this function. +/// +/// **Caller invariant:** The entity must have a `StableEntityId` component. +/// All other components are optional — missing components produce sensible defaults +/// in the output (and will be reconstructed as defaults by `deserialize_npc_from_frozen`). +/// +/// # Panics +/// Panics if the entity has no `StableEntityId` component. +pub fn serialize_npc_to_frozen(entity: Entity, world: &World) -> NpcSaveState { + let position = world + .get::<TilePosition>(entity) + .copied() + .unwrap_or_else(|| TilePosition::new(0, 0, 0)); + + let stable_id = world + .get::<StableEntityId>(entity) + .map(|s| s.0) + .expect("NPC entity must have StableEntityId before serialization (#96)"); + + let secret = world.get::<Secret>(entity).cloned(); + let secret_severity = secret + .as_ref() + .map(|s| s.severity) + .unwrap_or(SecretSeverity::Minor); + + let (current_stress, tolerance_threshold) = world + .get::<ToleranceThreshold>(entity) + .map(|t| (t.current_stress, t.threshold)) + .unwrap_or((0, 50)); + + NpcSaveState { + stable_id, + position, + secret_severity, + relationships: world.get::<Relationships>(entity).cloned(), + current_stress, + tolerance_threshold, + contentment: world + .get::<Contentment>(entity) + .map(|c| c.level) + .unwrap_or(0), + knowledge_graph: world.get::<KnowledgeGraph>(entity).cloned(), + want: world.get::<Want>(entity).cloned(), + secret, + routine: world.get::<DailyRoutine>(entity).cloned(), + information_inventory: world.get::<InformationInventory>(entity).cloned(), + personality_traits: world.get::<PersonalityTraits>(entity).cloned(), + tell_system: world.get::<TellSystem>(entity).cloned(), + skill_set: world.get::<SkillSet>(entity).cloned(), + combat_capability: world.get::<CombatCapability>(entity).cloned(), + mood_state: world.get::<MoodState>(entity).cloned(), + job_performance: world.get::<JobPerformance>(entity).cloned(), + template_ownership: world.get::<TemplateOwnership>(entity).cloned(), + } +} + +/// Deserialize a frozen `NpcSaveState` and re-spawn a full NPC entity. +/// +/// Used by the tier eviction system when reactivating an entity from `StateSaved`. +/// Reconstructs all D-024 axis components from the frozen state. +/// +/// **Caller responsibilities after calling this function:** +/// 1. Register the returned `Entity` with `EntityRegistry` (StableId→Entity mapping). +/// 2. Assign the appropriate tier marker (`ActiveSim` or `BackgroundSim`). +/// +/// Optional fields that are absent in `state` are reconstructed with sensible defaults: +/// - `Want`: defaults to `Safety` at intensity 5 (conservative non-disruptive default) +/// - `Secret`: reconstructed from `secret_severity` with empty description +/// - `MoodState`, `JobPerformance`: their `Default` implementations +/// +/// Components excluded from reconstruction (see `NpcSaveState` doc for rationale): +/// `NpcVisionState`, `NpcMemory`, `PlayerAwareness` are reset to their `Default` states. +pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> Entity { + let want = state.want.clone().unwrap_or(Want { + primary: WantKind::Safety, // conservative fallback — see flag comment above + intensity: 5, + description: String::new(), + }); + + let secret = state.secret.clone().unwrap_or(crate::npc::Secret { + description: String::new(), + severity: state.secret_severity, + known_by: vec![], + }); + + let relationships = state + .relationships + .clone() + .unwrap_or(Relationships { entries: vec![] }); + + let tolerance = ToleranceThreshold { + current_stress: state.current_stress, + threshold: state.tolerance_threshold, + }; + + let contentment = Contentment { + level: state.contentment, + }; + + let kg = state + .knowledge_graph + .clone() + .unwrap_or_else(KnowledgeGraph::new); + + // Spawn the entity with all required components. Tier marker (ActiveSim / + // BackgroundSim) is NOT added here — the caller assigns it after registration. + let entity = world + .spawn(( + Npc, + state.position, + StableEntityId(state.stable_id), + want, + secret, + relationships, + tolerance, + contentment, + kg, + state.mood_state.clone().unwrap_or_default(), + state.job_performance.clone().unwrap_or_default(), + // Runtime-computed components: reset to default on reactivation. + NpcVisionState::default(), + NpcMemory::default(), + PlayerAwareness::default(), + )) + .id(); + + // Optional axis components — insert only if present in frozen state. + { + let mut em = world.entity_mut(entity); + + if let Some(routine) = state.routine.clone() { + em.insert(routine); + } + if let Some(inventory) = state.information_inventory.clone() { + em.insert(inventory); + } + if let Some(traits) = state.personality_traits.clone() { + em.insert(traits); + } + if let Some(tells) = state.tell_system.clone() { + em.insert(tells); + } + if let Some(skills) = state.skill_set.clone() { + em.insert(skills); + } + if let Some(combat) = state.combat_capability.clone() { + em.insert(combat); + } + // Restore template ownership if present — never reassigned after initial spawn (D-025). + if let Some(ownership) = state.template_ownership.clone() { + em.insert(ownership); + } + } + + entity +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::knowledge::graph::KnowledgeGraph; + use crate::knowledge::registry::StableEntityId; + use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId}; + use crate::npc::relationships::RelationshipGraph; + use crate::simulation::movement::TilePosition; + use crate::simulation::time::TickRate; + + fn minimal_save_state() -> SaveStateV1 { + SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 0, + tick_rate: TickRate::Full, + seed: 42, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], + open_doors: vec![], + } + } + + // ----------------------------------------------------------------------- + // Roundtrip tests: serialize → deserialize → re-serialize → bytes match + // ----------------------------------------------------------------------- + + #[test] + fn empty_save_state_roundtrips() { + // Spec (#256): roundtrip must produce identical state. + // Strategy: bytes(original) == bytes(roundtrip(original)) + let state = minimal_save_state(); + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "empty save state must roundtrip losslessly"); + } + + #[test] + fn format_version_preserved_in_roundtrip() { + let state = minimal_save_state(); + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + assert_eq!( + recovered.format_version, SAVE_FORMAT_VERSION, + "format version must survive roundtrip" + ); + } + + #[test] + fn tick_and_seed_preserved_in_roundtrip() { + let mut state = minimal_save_state(); + state.tick = 12345; + state.seed = 0xDEADBEEF; + state.tick_rate = TickRate::Half; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + + assert_eq!(recovered.tick, 12345); + assert_eq!(recovered.seed, 0xDEADBEEF); + assert_eq!(recovered.tick_rate, TickRate::Half); + } + + #[test] + fn npc_states_roundtrip_with_position_and_axes() { + // Spec (#256): per-NPC D-024 axis values must survive serialization. + let mut state = minimal_save_state(); + state.npc_states = vec![ + NpcSaveState { + stable_id: StableId(101), + position: TilePosition::new(10, 20, 0), + secret_severity: crate::npc::SecretSeverity::Major, + relationships: None, + current_stress: 45, + tolerance_threshold: 80, + contentment: -15, + knowledge_graph: None, + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + template_ownership: None, + }, + NpcSaveState { + stable_id: StableId(202), + position: TilePosition::new(5, 5, 1), + secret_severity: crate::npc::SecretSeverity::Minor, + relationships: None, + current_stress: 0, + tolerance_threshold: 60, + contentment: 30, + knowledge_graph: None, + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + template_ownership: None, + }, + ]; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + + assert_eq!(recovered.npc_states.len(), 2); + + let npc1 = &recovered.npc_states[0]; + assert_eq!(npc1.stable_id, StableId(101)); + assert_eq!(npc1.position, TilePosition::new(10, 20, 0)); + assert_eq!(npc1.secret_severity, crate::npc::SecretSeverity::Major); + assert_eq!(npc1.current_stress, 45); + assert_eq!(npc1.tolerance_threshold, 80); + assert_eq!(npc1.contentment, -15); + + let npc2 = &recovered.npc_states[1]; + assert_eq!(npc2.stable_id, StableId(202)); + assert_eq!(npc2.contentment, 30); + } + + #[test] + fn player_knowledge_graph_roundtrips() { + // Spec (#256): KnowledgeGraph is "already serializable" — verify it + // survives a save state roundtrip intact. + use crate::knowledge::types::{KnowledgeSource, KnowledgeState}; + use crate::simulation::movement::TilePosition; + + let mut state = minimal_save_state(); + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(StableId(50), TilePosition::new(3, 3, 0), 5); + + // Add a fact + kg.facts.insert( + FactId("contraband.ring_exists".into()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 100, + disclosure_blocked: false, + }, + ); + state.player_knowledge = kg; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + + // Roundtrip the recovered state again — bytes must still match + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!( + bytes, bytes2, + "KnowledgeGraph roundtrip must be idempotent" + ); + } + + #[test] + fn relationship_graph_roundtrips() { + use crate::npc::relationships::RelationshipEdge; + use crate::npc::RelationshipKind; + use crate::knowledge::types::StableId; + + let mut state = minimal_save_state(); + let mut rg = RelationshipGraph::new(); + rg.set_relationship( + StableId(1), + StableId(2), + RelationshipEdge { + kind: RelationshipKind::Friend, + trust: 5, + history: vec![], + last_interaction_tick: 0, + }, + ); + state.relationship_graph = rg; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless"); + } + + #[test] + fn npc_with_knowledge_graph_roundtrips() { + // Spec (#256): Active-tier NPCs carry KnowledgeGraph — must survive roundtrip. + let mut state = minimal_save_state(); + let mut npc_kg = KnowledgeGraph::new(); + npc_kg.observe_entity(StableId(99), TilePosition::new(7, 7, 0), 10); + + state.npc_states = vec![NpcSaveState { + stable_id: StableId(1), + position: TilePosition::new(1, 1, 0), + secret_severity: crate::npc::SecretSeverity::Minor, + relationships: None, + current_stress: 0, + tolerance_threshold: 50, + contentment: 0, + knowledge_graph: Some(npc_kg), + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + template_ownership: None, + }]; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!( + bytes, bytes2, + "NPC KnowledgeGraph roundtrip must be lossless" + ); + } + + #[test] + fn save_format_version_constant_is_one() { + // Document the version explicitly so CI catches unintentional bumps. + assert_eq!(SAVE_FORMAT_VERSION, 1); + } + + // ----------------------------------------------------------------------- + // Tier eviction serialization primitives (#96) + // ----------------------------------------------------------------------- + + fn spawn_minimal_npc(world: &mut World, stable_id: StableId) -> Entity { + use crate::npc::{ + Contentment, Relationships, Secret, SecretSeverity, ToleranceThreshold, Want, WantKind, + }; + use crate::npc::mood::MoodState; + use crate::npc::vision::{NpcMemory, NpcVisionState}; + use crate::npc::awareness::PlayerAwareness; + + world + .spawn(( + Npc, + StableEntityId(stable_id), + TilePosition::new(5, 10, 0), + Want { + primary: WantKind::Safety, + intensity: 7, + description: "wants safety".into(), + }, + Secret { + description: "has a minor secret".into(), + severity: SecretSeverity::Minor, + known_by: vec![], + }, + Relationships { entries: vec![] }, + ToleranceThreshold { + current_stress: 30, + threshold: 70, + }, + Contentment { level: 15 }, + MoodState::default(), + crate::npc::JobPerformance::default(), + KnowledgeGraph::new(), + NpcVisionState::default(), + NpcMemory::default(), + PlayerAwareness::default(), + )) + .id() + } + + #[test] + fn serialize_npc_to_frozen_captures_stable_id_and_position() { + let mut world = World::new(); + let entity = spawn_minimal_npc(&mut world, StableId(42)); + + let frozen = serialize_npc_to_frozen(entity, &world); + + assert_eq!(frozen.stable_id, StableId(42)); + assert_eq!(frozen.position, TilePosition::new(5, 10, 0)); + } + + #[test] + fn serialize_npc_to_frozen_captures_axes() { + use crate::npc::SecretSeverity; + let mut world = World::new(); + let entity = spawn_minimal_npc(&mut world, StableId(1)); + + let frozen = serialize_npc_to_frozen(entity, &world); + + assert_eq!(frozen.secret_severity, SecretSeverity::Minor); + assert_eq!(frozen.current_stress, 30); + assert_eq!(frozen.tolerance_threshold, 70); + assert_eq!(frozen.contentment, 15); + + // Full optional axes should be populated when components exist + assert!(frozen.want.is_some(), "want should be captured"); + assert!(frozen.secret.is_some(), "secret should be captured"); + } + + #[test] + fn serialize_deserialize_roundtrip_produces_identical_component_values() { + // Spec (#96): serialize + deserialize produces an entity with identical values. + let mut world = World::new(); + let original = spawn_minimal_npc(&mut world, StableId(77)); + + // Serialize + let frozen = serialize_npc_to_frozen(original, &world); + + // Deserialize into a new entity + let restored = deserialize_npc_from_frozen(&frozen, &mut world); + + // Verify StableEntityId matches + let orig_stable = world.get::<StableEntityId>(original).unwrap().0; + let rest_stable = world.get::<StableEntityId>(restored).unwrap().0; + assert_eq!(orig_stable, rest_stable, "StableId must match"); + + // Position + let orig_pos = world.get::<TilePosition>(original).copied().unwrap(); + let rest_pos = world.get::<TilePosition>(restored).copied().unwrap(); + assert_eq!(orig_pos, rest_pos, "position must match"); + + // Tolerance + let orig_tol = world.get::<ToleranceThreshold>(original).cloned().unwrap(); + let rest_tol = world.get::<ToleranceThreshold>(restored).cloned().unwrap(); + assert_eq!(orig_tol.current_stress, rest_tol.current_stress); + assert_eq!(orig_tol.threshold, rest_tol.threshold); + + // Contentment + let orig_con = world.get::<Contentment>(original).cloned().unwrap(); + let rest_con = world.get::<Contentment>(restored).cloned().unwrap(); + assert_eq!(orig_con.level, rest_con.level, "contentment must match"); + + // Want + let orig_want = world.get::<Want>(original).cloned().unwrap(); + let rest_want = world.get::<Want>(restored).cloned().unwrap(); + assert_eq!(orig_want.primary, rest_want.primary, "want.primary must match"); + assert_eq!(orig_want.intensity, rest_want.intensity, "want.intensity must match"); + + // Secret severity + let orig_secret = world.get::<Secret>(original).cloned().unwrap(); + let rest_secret = world.get::<Secret>(restored).cloned().unwrap(); + assert_eq!(orig_secret.severity, rest_secret.severity, "secret severity must match"); + } + + #[test] + fn deserialize_npc_without_optional_axes_uses_safe_defaults() { + // Spec (#96): optional fields absent in frozen state produce sensible defaults. + use crate::npc::SecretSeverity; + let frozen = NpcSaveState { + stable_id: StableId(999), + position: TilePosition::new(0, 0, 0), + secret_severity: SecretSeverity::Moderate, + relationships: None, + current_stress: 10, + tolerance_threshold: 50, + contentment: 0, + knowledge_graph: None, + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + template_ownership: None, + }; + + let mut world = World::new(); + let entity = deserialize_npc_from_frozen(&frozen, &mut world); + + // Entity must exist with required components + assert!(world.get::<Npc>(entity).is_some()); + assert!(world.get::<StableEntityId>(entity).is_some()); + assert!(world.get::<ToleranceThreshold>(entity).is_some()); + assert!(world.get::<Contentment>(entity).is_some()); + assert!(world.get::<Want>(entity).is_some(), "Want defaults to Safety"); + assert!(world.get::<Secret>(entity).is_some(), "Secret built from secret_severity"); + + // Secret severity must be preserved from the legacy field + let secret = world.get::<Secret>(entity).unwrap(); + assert_eq!(secret.severity, SecretSeverity::Moderate); + + // Optional axes absent in frozen state → not inserted or use defaults + assert!(world.get::<DailyRoutine>(entity).is_none(), "routine absent when not frozen"); + } + + #[test] + fn frozen_npc_roundtrips_via_messagepack() { + // Spec (#96): NpcSaveState must survive MessagePack roundtrip. + let mut world = World::new(); + let entity = spawn_minimal_npc(&mut world, StableId(55)); + let frozen = serialize_npc_to_frozen(entity, &world); + + // Wrap in SaveStateV1 for MessagePack encoding + let save = SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 100, + tick_rate: TickRate::Full, + seed: 12, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![frozen], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], + open_doors: vec![], + }; + + let bytes = save.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "frozen NPC state must roundtrip via MessagePack"); + } + + #[test] + fn serialize_npc_panics_without_stable_entity_id() { + // Spec (#96): StableEntityId is required — missing it is a programmer error. + let mut world = World::new(); + let entity = world.spawn((Npc, TilePosition::new(0, 0, 0))).id(); + + // World doesn't implement UnwindSafe — wrap in AssertUnwindSafe. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + serialize_npc_to_frozen(entity, &world); + })); + assert!(result.is_err(), "must panic without StableEntityId"); + } +} diff --git a/server/src/simulation/spatial.rs b/server/src/simulation/spatial.rs index 3bde13cbb..678285604 100644 --- a/server/src/simulation/spatial.rs +++ b/server/src/simulation/spatial.rs @@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync { /// Return all entities at the exact `position`. fn entities_at(&self, position: &TilePosition) -> Vec<Entity>; + /// Return all entities within Manhattan distance `radius` of `position`, + /// **including** entities exactly at `position`. Single-pass alternative to + /// `entities_in_range` + `entities_at`. + fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> { + let mut result = self.entities_in_range(position, radius); + result.extend(self.entities_at(position)); + result + } + /// Insert or update an entity's position in the index. fn update(&mut self, entity: Entity, position: TilePosition); @@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex { .collect() } + fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> { + self.entries + .iter() + .filter(|(_, pos)| { + pos == position + || pos + .manhattan_distance(position) + .is_some_and(|d| d <= radius) + }) + .map(|(entity, _)| *entity) + .collect() + } + fn update(&mut self, entity: Entity, position: TilePosition) { if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) { entry.1 = position; diff --git a/server/src/simulation/tier.rs b/server/src/simulation/tier.rs index bcb645bce..9a312fbd8 100644 --- a/server/src/simulation/tier.rs +++ b/server/src/simulation/tier.rs @@ -1,9 +1,21 @@ // Simulation tier system // Implements D-026: Active/Background/State-saved/Ungenerated tiers // Tier transitions based on player approach distance (#99). +// Scope tag system: NPCs with active scope tags stay pinned to ActiveSim (#98). +// Timestamp-based eviction: LRU eviction when ActiveSim exceeds capacity (#97). + +use std::collections::{BTreeSet, BinaryHeap}; +use std::cmp::Reverse; use bevy_app::prelude::*; use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::registry::StableEntityId; +use crate::knowledge::types::KnowledgeConfidence; +use crate::npc::{Npc, RelationshipKind}; +use crate::npc::relationships::RelationshipGraph; use crate::simulation::movement::{PlayerCharacter, TilePosition}; // --- Tier radius constants (D-026) --- @@ -38,20 +50,365 @@ pub struct BackgroundSim; #[derive(Component, Debug, Clone, Copy, Default)] pub struct StateSaved; +// --------------------------------------------------------------------------- +// Eviction system (D-026, #97) +// --------------------------------------------------------------------------- + +/// Maximum number of entities in `ActiveSim` before LRU eviction kicks in (D-026). +pub const ACTIVE_SIM_CAPACITY: usize = 80; + +/// Tracks the tick at which the player last interacted with or observed an NPC (#97). +/// Updated by `update_last_interaction_tick` when an NPC is in the player's LOS. +/// Used by `evict_excess_active` as the LRU sort key. +#[derive(Component, Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct LastInteractionTick(pub u64); + +/// Tracks current `ActiveSim` entity count vs. capacity (#97, D-026). +/// Updated each tick by `evict_excess_active`. +#[derive(Resource, Debug, Clone)] +pub struct SimSpacePressure { + /// Number of entities in `ActiveSim` at the start of the current tick's eviction pass. + /// + /// Set by `evict_excess_active` *before* any evictions run. Eviction commands are + /// deferred (applied after the system), so `active_count` reflects the pre-eviction + /// count, not the post-eviction count. Consumers (e.g., HUD pressure display) should + /// treat this as the high-water mark for the tick. + pub active_count: usize, + /// Capacity ceiling. + pub capacity: usize, +} + +impl Default for SimSpacePressure { + fn default() -> Self { + Self { + active_count: 0, + capacity: ACTIVE_SIM_CAPACITY, + } + } +} + +// --------------------------------------------------------------------------- +// Scope tag system (D-026, #98) +// --------------------------------------------------------------------------- + +/// Scope tag kinds: reasons why an NPC stays pinned to `ActiveSim` (D-026). +/// +/// Four variants track distinct reasons for pinning. An NPC may have multiple +/// reasons simultaneously — all are tracked in `ScopeTag`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum ScopeTagKind { + /// NPC is in the player's immediate neighborhood. + /// Set at session start for NPCs within `ACTIVE_RADIUS`. Managed by + /// `assign_neighborhood_tags_on_start` (deferred: future sprint). + Neighborhood, + /// NPC is involved in an active quest. + /// Reserved for the quest system (deferred: future sprint). + ActiveQuest, + /// NPC has a `Friend` or `Colleague` relationship with the player character. + /// Assigned by `assign_scope_tags` each tick from `RelationshipGraph`. + Colleague, + /// NPC is known to the player with confidence >= `KnowsOf`. + /// Assigned by `assign_scope_tags` each tick from player `KnowledgeGraph`. + KnownContact, +} + +/// Scope tag component: which scope tags currently apply to this NPC (D-026). +/// +/// NPCs carrying at least one scope tag are kept in `ActiveSim` regardless of +/// distance or LRU eviction pressure. `ScopePinned` is the eviction guard; +/// this component is the source of truth. +/// +/// Assignment: +/// - `KnownContact` and `Colleague`: recomputed by `assign_scope_tags` each tick. +/// - `Neighborhood`: set at session start (see `ScopeTagKind::Neighborhood`). +/// - `ActiveQuest`: reserved for future quest system. +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct ScopeTag { + pub tags: BTreeSet<ScopeTagKind>, +} + +impl ScopeTag { + /// Create a `ScopeTag` with a single initial kind. + pub fn with(kind: ScopeTagKind) -> Self { + let mut tags = BTreeSet::new(); + tags.insert(kind); + Self { tags } + } + + /// Add a scope tag kind. + pub fn add(&mut self, kind: ScopeTagKind) { + self.tags.insert(kind); + } + + /// Remove a scope tag kind. + pub fn remove(&mut self, kind: ScopeTagKind) { + self.tags.remove(&kind); + } + + /// True if this NPC carries at least one scope tag. + pub fn is_pinned(&self) -> bool { + !self.tags.is_empty() + } + + /// True if this specific kind is present. + pub fn contains(&self, kind: ScopeTagKind) -> bool { + self.tags.contains(&kind) + } +} + +/// Marker component: this NPC is scope-pinned — the eviction system must skip it. +/// +/// Kept in sync with `ScopeTag` by `sync_scope_pins`. Always use `ScopeTag` +/// as the source of truth; treat `ScopePinned` as a query-optimisation cache. +#[derive(Component, Debug, Clone, Copy, Default)] +pub struct ScopePinned; + /// Plugin registering the tier marker components and the tier transition system. pub struct TierPlugin; impl Plugin for TierPlugin { fn build(&self, app: &mut App) { + app.init_resource::<SimSpacePressure>(); + // Tier transition runs after movement so positions are current. app.add_systems( Update, update_tier_markers.after(crate::simulation::movement::validate_movement), ); + // Scope tag assignment runs each tick to keep KnownContact / Colleague current. + // Must run before sync_scope_pins so pins are correct before eviction checks. + // Eviction runs after scope pins are synced (respects ScopePinned). + // LastInteractionTick update runs after visibility geometry. + app.add_systems( + Update, + ( + assign_scope_tags, + sync_scope_pins.after(assign_scope_tags), + update_last_interaction_tick + .after(crate::perception::observer::compute_visibility_geometry), + evict_excess_active + .after(sync_scope_pins) + .after(update_tier_markers), + ), + ); tracing::debug!("TierPlugin initialized"); } } +// --------------------------------------------------------------------------- +// Scope tag systems (D-026, #98) +// --------------------------------------------------------------------------- + +/// System: assign `KnownContact` and `Colleague` scope tags from player epistemics. +/// +/// Runs each tick. Clears and recomputes `KnownContact` and `Colleague` tags for all +/// NPCs based on: +/// - `KnownContact`: player `KnowledgeGraph` has an entry for this NPC with +/// confidence >= `KnowsOf`. +/// - `Colleague`: global `RelationshipGraph` has an edge from the player to this NPC +/// with kind `Friend` or `Colleague`. +/// +/// `Neighborhood` and `ActiveQuest` tags are NOT touched by this system: +/// - `Neighborhood` is set at session start and persists (future sprint). +/// - `ActiveQuest` is reserved for the quest system (future sprint). +/// +/// No-op when there is no `PlayerCharacter` entity. +pub fn assign_scope_tags( + player_query: Query<(&KnowledgeGraph, &StableEntityId), With<PlayerCharacter>>, + rel_graph: Res<RelationshipGraph>, + mut npcs: Query<(Entity, &StableEntityId, Option<&mut ScopeTag>), With<Npc>>, + mut commands: Commands, +) { + let Ok((player_kg, player_stable)) = player_query.single() else { + return; + }; + let player_id = player_stable.0; + + // Collect KnownContact set: entities in player KG with confidence >= KnowsOf. + // BTreeSet for deterministic iteration (D-010). + let known_contacts: BTreeSet<_> = player_kg + .entities + .iter() + .filter(|(_, ek)| ek.confidence >= KnowledgeConfidence::KnowsOf) + .map(|(id, _)| *id) + .collect(); + + // Collect Colleague set: player → NPC relationship edges with Friend/Colleague kind. + let colleagues: BTreeSet<_> = rel_graph + .relationships_of(&player_id) + .into_iter() + .filter(|(_, edge)| { + matches!(edge.kind, RelationshipKind::Friend | RelationshipKind::Colleague) + }) + .map(|(target_id, _)| *target_id) + .collect(); + + for (entity, npc_stable, maybe_scope_tag) in &mut npcs { + let npc_id = npc_stable.0; + let is_known = known_contacts.contains(&npc_id); + let is_colleague = colleagues.contains(&npc_id); + + match maybe_scope_tag { + Some(mut scope_tag) => { + // Remove computed tags, then re-add if still applicable. + scope_tag.remove(ScopeTagKind::KnownContact); + scope_tag.remove(ScopeTagKind::Colleague); + if is_known { + scope_tag.add(ScopeTagKind::KnownContact); + } + if is_colleague { + scope_tag.add(ScopeTagKind::Colleague); + } + } + None if is_known || is_colleague => { + // Create a new ScopeTag component for this NPC. + let mut scope_tag = ScopeTag::default(); + if is_known { + scope_tag.add(ScopeTagKind::KnownContact); + } + if is_colleague { + scope_tag.add(ScopeTagKind::Colleague); + } + commands.entity(entity).insert(scope_tag); + } + None => {} // NPC not known or related — no scope tag needed. + } + } +} + +/// System: keep `ScopePinned` markers in sync with `ScopeTag` components. +/// +/// Runs after `assign_scope_tags`. For each NPC: +/// - `ScopeTag` present and non-empty → add `ScopePinned` (if not already present). +/// - `ScopeTag` absent or empty → remove `ScopePinned` (if present). +/// +/// The eviction system (#97) queries `Without<ScopePinned>` to skip pinned NPCs. +pub fn sync_scope_pins( + mut commands: Commands, + needs_pin: Query<(Entity, &ScopeTag), Without<ScopePinned>>, + may_need_unpin: Query<(Entity, Option<&ScopeTag>), With<ScopePinned>>, +) { + // Add ScopePinned to NPCs that have a non-empty ScopeTag. + for (entity, scope_tag) in &needs_pin { + if scope_tag.is_pinned() { + commands.entity(entity).insert(ScopePinned); + } + } + + // Remove ScopePinned from NPCs whose ScopeTag is absent or empty. + for (entity, maybe_scope_tag) in &may_need_unpin { + let still_pinned = maybe_scope_tag.map(|s| s.is_pinned()).unwrap_or(false); + if !still_pinned { + commands.entity(entity).remove::<ScopePinned>(); + } + } +} + +// --------------------------------------------------------------------------- +// Eviction systems (D-026, #97) +// --------------------------------------------------------------------------- + +/// System: update `LastInteractionTick` for NPCs visible to the player (#97). +/// +/// Runs after visibility geometry is computed. Any NPC at a visible position +/// (in the player's LOS) gets its `LastInteractionTick` set to the current tick. +/// NPCs without this component get it inserted on first observation. +pub fn update_last_interaction_tick( + time: Res<crate::simulation::time::SimulationTime>, + vis_geo: Res<crate::perception::query::VisibilityGeometry>, + mut npcs_with_tick: Query<(&TilePosition, &mut LastInteractionTick), With<Npc>>, + npcs_without_tick: Query<(Entity, &TilePosition), (With<Npc>, Without<LastInteractionTick>)>, + mut commands: Commands, +) { + let current_tick = time.tick; + + // Update existing LastInteractionTick for visible NPCs. + for (pos, mut last_tick) in &mut npcs_with_tick { + if pos.z == vis_geo.observer_z + && vis_geo.visible_positions.contains(&(pos.x, pos.y)) + { + last_tick.0 = current_tick; + } + } + + // Insert LastInteractionTick for NPCs that don't have it yet but are visible. + for (entity, pos) in &npcs_without_tick { + if pos.z == vis_geo.observer_z + && vis_geo.visible_positions.contains(&(pos.x, pos.y)) + { + commands.entity(entity).insert(LastInteractionTick(current_tick)); + } + } +} + +/// System: evict excess `ActiveSim` entities when count exceeds capacity (#97). +/// +/// When more than `ACTIVE_SIM_CAPACITY` entities are in `ActiveSim`: +/// 1. Skip all `ScopePinned` entities (they stay Active regardless). +/// 2. Sort remaining by `LastInteractionTick` (oldest first) via min-heap. +/// 3. Demote the oldest N entities to `BackgroundSim` (or `StateSaved` if beyond +/// background radius). +/// +/// Updates `SimSpacePressure` resource with current counts. +pub fn evict_excess_active( + mut commands: Commands, + player_query: Query<&TilePosition, With<PlayerCharacter>>, + active_npcs: Query< + (Entity, &TilePosition, Option<&LastInteractionTick>), + (With<ActiveSim>, With<Npc>, Without<ScopePinned>), + >, + active_count_query: Query<(), With<ActiveSim>>, + mut pressure: ResMut<SimSpacePressure>, +) { + let total_active = active_count_query.iter().count(); + pressure.active_count = total_active; + + if total_active <= pressure.capacity { + return; + } + + let excess = total_active - pressure.capacity; + let Ok(player_pos) = player_query.single() else { + return; + }; + + // Min-heap keyed by LastInteractionTick (oldest = smallest = evicted first). + // Entities without LastInteractionTick get tick 0 (most stale). + // NOTE: Ties in tick value are broken by Entity index, which is non-deterministic + // across runs (bevy Entity allocation order). For v0.1 this is acceptable — + // deterministic replay (D-010 principle 4) replays inputs, not eviction order. + // If eviction order must be deterministic, key by (tick, StableId) instead. + let mut heap: BinaryHeap<Reverse<(u64, Entity, TilePosition)>> = BinaryHeap::new(); + for (entity, pos, maybe_tick) in &active_npcs { + let tick = maybe_tick.map(|t| t.0).unwrap_or(0); + heap.push(Reverse((tick, entity, *pos))); + } + + let mut evicted = 0; + while evicted < excess { + let Some(Reverse((_, entity, pos))) = heap.pop() else { + break; + }; + + let dist = tile_distance(player_pos, &pos); + if dist > BACKGROUND_RADIUS { + commands.entity(entity).remove::<ActiveSim>().insert(StateSaved); + } else { + commands.entity(entity).remove::<ActiveSim>().insert(BackgroundSim); + } + evicted += 1; + } + + if evicted > 0 { + tracing::debug!( + "evicted {} excess ActiveSim entities (was {}, cap {})", + evicted, + total_active, + pressure.capacity, + ); + } +} + // --- Tier transition system (D-026, #99) --- /// Manhattan tile distance between two positions, returning `u32::MAX` for @@ -432,4 +789,541 @@ mod tests { assert!(world.get::<ActiveSim>(npc).is_none(), "demoted to Background"); assert!(world.get::<BackgroundSim>(npc).is_some()); } + + // ----------------------------------------------------------------------- + // ScopeTag component tests (#98, D-026) + // ----------------------------------------------------------------------- + + #[test] + fn scope_tag_with_creates_single_kind() { + let tag = ScopeTag::with(ScopeTagKind::KnownContact); + assert!(tag.contains(ScopeTagKind::KnownContact)); + assert!(!tag.contains(ScopeTagKind::Colleague)); + assert!(tag.is_pinned()); + } + + #[test] + fn scope_tag_add_and_remove() { + let mut tag = ScopeTag::default(); + assert!(!tag.is_pinned(), "new ScopeTag is empty"); + + tag.add(ScopeTagKind::Colleague); + assert!(tag.is_pinned()); + assert!(tag.contains(ScopeTagKind::Colleague)); + + tag.add(ScopeTagKind::KnownContact); + assert!(tag.contains(ScopeTagKind::KnownContact)); + + tag.remove(ScopeTagKind::Colleague); + assert!(!tag.contains(ScopeTagKind::Colleague)); + assert!(tag.is_pinned(), "still pinned by KnownContact"); + + tag.remove(ScopeTagKind::KnownContact); + assert!(!tag.is_pinned(), "unpinned when all tags removed"); + } + + #[test] + fn scope_tag_multiple_kinds_coexist() { + let mut tag = ScopeTag::default(); + tag.add(ScopeTagKind::Neighborhood); + tag.add(ScopeTagKind::ActiveQuest); + tag.add(ScopeTagKind::Colleague); + tag.add(ScopeTagKind::KnownContact); + + assert_eq!(tag.tags.len(), 4, "all four kinds present"); + assert!(tag.is_pinned()); + } + + // ----------------------------------------------------------------------- + // sync_scope_pins system tests (#98) + // ----------------------------------------------------------------------- + + fn run_sync_scope_pins(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(sync_scope_pins); + schedule.run(world); + } + + #[test] + fn sync_scope_pins_adds_scope_pinned_for_non_empty_tag() { + let mut world = World::new(); + let npc = world + .spawn((Npc, ScopeTag::with(ScopeTagKind::KnownContact))) + .id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::<ScopePinned>(npc).is_some(), + "ScopePinned added for non-empty ScopeTag" + ); + } + + #[test] + fn sync_scope_pins_does_not_add_for_empty_tag() { + let mut world = World::new(); + let npc = world.spawn((Npc, ScopeTag::default())).id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::<ScopePinned>(npc).is_none(), + "ScopePinned must NOT be added for empty ScopeTag" + ); + } + + #[test] + fn sync_scope_pins_removes_scope_pinned_when_tag_emptied() { + let mut world = World::new(); + // Start with ScopePinned already set but ScopeTag now empty. + let npc = world.spawn((Npc, ScopePinned, ScopeTag::default())).id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::<ScopePinned>(npc).is_none(), + "ScopePinned removed when ScopeTag is empty" + ); + } + + #[test] + fn sync_scope_pins_removes_scope_pinned_when_tag_absent() { + let mut world = World::new(); + // NPC has ScopePinned but no ScopeTag component at all. + let npc = world.spawn((Npc, ScopePinned)).id(); + + run_sync_scope_pins(&mut world); + + assert!( + world.get::<ScopePinned>(npc).is_none(), + "ScopePinned removed when ScopeTag absent" + ); + } + + #[test] + fn sync_scope_pins_keeps_existing_scope_pinned() { + // An NPC that already has ScopePinned AND a non-empty ScopeTag should remain pinned. + let mut world = World::new(); + let npc = world + .spawn((Npc, ScopePinned, ScopeTag::with(ScopeTagKind::Colleague))) + .id(); + + run_sync_scope_pins(&mut world); + + // After sync, the NPC should still have ScopePinned (it was already there + // AND the scope tag is non-empty — so no change needed). + assert!( + world.get::<ScopePinned>(npc).is_some(), + "ScopePinned preserved for non-empty ScopeTag" + ); + } + + // ----------------------------------------------------------------------- + // assign_scope_tags system tests (#98) + // ----------------------------------------------------------------------- + + fn run_assign_scope_tags(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(assign_scope_tags); + schedule.run(world); + } + + #[test] + fn assign_scope_tags_no_op_without_player() { + let mut world = World::new(); + world.init_resource::<RelationshipGraph>(); + + // NPC exists but no PlayerCharacter + let npc = world.spawn((Npc, StableEntityId(crate::knowledge::types::StableId(1)))).id(); + + run_assign_scope_tags(&mut world); + + // No ScopeTag should be assigned — no player + assert!(world.get::<ScopeTag>(npc).is_none()); + } + + #[test] + fn assign_scope_tags_known_contact_from_player_kg() { + use crate::knowledge::types::StableId; + + let mut world = World::new(); + world.init_resource::<RelationshipGraph>(); + + let npc_stable = StableId(10); + let player_stable = StableId(1); + + // Set up player with a KnowledgeGraph that knows the NPC at KnowsOf level. + let mut player_kg = KnowledgeGraph::new(); + player_kg.observe_entity(npc_stable, make_pos(5, 5), 0); + + world.spawn(( + PlayerCharacter, + make_pos(0, 0), + player_kg, + StableEntityId(player_stable), + )); + + // Spawn the NPC + let npc = world + .spawn((Npc, make_pos(10, 0), StableEntityId(npc_stable))) + .id(); + + run_assign_scope_tags(&mut world); + + let scope_tag = world.get::<ScopeTag>(npc).expect("ScopeTag should be assigned"); + assert!( + scope_tag.contains(ScopeTagKind::KnownContact), + "NPC known at KnowsOf level should get KnownContact tag" + ); + } + + #[test] + fn assign_scope_tags_colleague_from_relationship_graph() { + use crate::knowledge::types::StableId; + use crate::npc::relationships::RelationshipEdge; + + let mut world = World::new(); + + let npc_stable = StableId(20); + let player_stable = StableId(1); + + // Player KG is empty — no KnownContact. + let player_kg = KnowledgeGraph::new(); + + world.spawn(( + PlayerCharacter, + make_pos(0, 0), + player_kg, + StableEntityId(player_stable), + )); + + // Set up RelationshipGraph with player → NPC as Friend. + let mut rel_graph = RelationshipGraph::new(); + rel_graph.set_relationship( + player_stable, + npc_stable, + RelationshipEdge { + kind: RelationshipKind::Friend, + trust: 5, + history: vec![], + last_interaction_tick: 0, + }, + ); + world.insert_resource(rel_graph); + + let npc = world + .spawn((Npc, make_pos(0, 5), StableEntityId(npc_stable))) + .id(); + + run_assign_scope_tags(&mut world); + + let scope_tag = world.get::<ScopeTag>(npc).expect("ScopeTag assigned for colleague"); + assert!( + scope_tag.contains(ScopeTagKind::Colleague), + "Friend relationship should grant Colleague scope tag" + ); + } + + #[test] + fn assign_scope_tags_does_not_affect_unknown_npcs() { + use crate::knowledge::types::StableId; + + let mut world = World::new(); + world.init_resource::<RelationshipGraph>(); + + let player_stable = StableId(1); + let player_kg = KnowledgeGraph::new(); // empty — knows nobody + + world.spawn(( + PlayerCharacter, + make_pos(0, 0), + player_kg, + StableEntityId(player_stable), + )); + + // NPC that the player doesn't know + let npc = world + .spawn((Npc, make_pos(10, 0), StableEntityId(StableId(99)))) + .id(); + + run_assign_scope_tags(&mut world); + + assert!( + world.get::<ScopeTag>(npc).is_none(), + "unknown NPC should not receive ScopeTag" + ); + } + + #[test] + fn scope_pinned_npc_in_query_without_scope_pinned_marker() { + // Verify that ScopePinned is a separate marker and Without<ScopePinned> + // correctly excludes pinned NPCs from eviction queries. + let mut world = World::new(); + let pinned = world.spawn((Npc, ScopePinned)).id(); + let unpinned = world.spawn(Npc).id(); + + let mut query = world.query_filtered::<Entity, (With<Npc>, Without<ScopePinned>)>(); + let unpinned_results: Vec<Entity> = query.iter(&world).collect(); + + assert_eq!(unpinned_results.len(), 1, "only one unpinned NPC"); + assert_eq!(unpinned_results[0], unpinned); + assert!(!unpinned_results.contains(&pinned), "pinned NPC excluded from eviction query"); + } + + // ----------------------------------------------------------------------- + // Eviction system tests (#97, D-026) + // ----------------------------------------------------------------------- + + fn run_evict_excess_active(world: &mut World) { + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(evict_excess_active); + schedule.run(world); + } + + #[test] + fn no_eviction_when_under_capacity() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 5, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // Spawn 3 active NPCs (under cap of 5) + let npc1 = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id(); + let npc2 = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id(); + let npc3 = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id(); + + run_evict_excess_active(&mut world); + + // All should remain Active + assert!(world.get::<ActiveSim>(npc1).is_some()); + assert!(world.get::<ActiveSim>(npc2).is_some()); + assert!(world.get::<ActiveSim>(npc3).is_some()); + } + + #[test] + fn evicts_oldest_when_over_capacity() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 2, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // 3 NPCs, cap=2 → must evict 1 (the oldest: tick 10) + let oldest = world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))).id(); + let mid = world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))).id(); + let newest = world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::<ActiveSim>(oldest).is_none(), "oldest evicted"); + assert!(world.get::<BackgroundSim>(oldest).is_some(), "oldest → Background"); + assert!(world.get::<ActiveSim>(mid).is_some(), "mid stays Active"); + assert!(world.get::<ActiveSim>(newest).is_some(), "newest stays Active"); + } + + #[test] + fn eviction_skips_scope_pinned() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 1, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // 2 NPCs, cap=1. The oldest is ScopePinned → skip it, evict the other. + let pinned = world.spawn(( + Npc, ActiveSim, ScopePinned, + ScopeTag::with(ScopeTagKind::KnownContact), + make_pos(5, 0), LastInteractionTick(5), + )).id(); + let unpinned = world.spawn(( + Npc, ActiveSim, + make_pos(6, 0), LastInteractionTick(20), + )).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::<ActiveSim>(pinned).is_some(), "pinned NPC stays Active"); + assert!(world.get::<ActiveSim>(unpinned).is_none(), "unpinned NPC evicted"); + assert!(world.get::<BackgroundSim>(unpinned).is_some()); + } + + #[test] + fn eviction_demotes_to_state_saved_if_beyond_background_radius() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 1, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // NPC at distance 200 (beyond BACKGROUND_RADIUS=120) → StateSaved + let far = world.spawn(( + Npc, ActiveSim, + make_pos(200, 0), LastInteractionTick(5), + )).id(); + // NPC at distance 5 (within ACTIVE_RADIUS) → stays + let near = world.spawn(( + Npc, ActiveSim, + make_pos(5, 0), LastInteractionTick(50), + )).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::<ActiveSim>(far).is_none(), "far NPC evicted"); + assert!(world.get::<StateSaved>(far).is_some(), "far NPC → StateSaved"); + assert!(world.get::<ActiveSim>(near).is_some(), "near NPC stays Active"); + } + + #[test] + fn eviction_handles_npcs_without_last_interaction_tick() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 1, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // NPC without LastInteractionTick defaults to tick 0 (most stale) + let no_tick = world.spawn((Npc, ActiveSim, make_pos(5, 0))).id(); + let with_tick = world.spawn(( + Npc, ActiveSim, + make_pos(6, 0), LastInteractionTick(100), + )).id(); + + run_evict_excess_active(&mut world); + + assert!(world.get::<ActiveSim>(no_tick).is_none(), "no-tick NPC evicted first"); + assert!(world.get::<BackgroundSim>(no_tick).is_some()); + assert!(world.get::<ActiveSim>(with_tick).is_some(), "with-tick NPC stays"); + } + + #[test] + fn sim_space_pressure_updated_after_eviction() { + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 2, + }); + world.spawn((PlayerCharacter, make_pos(0, 0))); + + world.spawn((Npc, ActiveSim, make_pos(5, 0), LastInteractionTick(10))); + world.spawn((Npc, ActiveSim, make_pos(6, 0), LastInteractionTick(20))); + world.spawn((Npc, ActiveSim, make_pos(7, 0), LastInteractionTick(30))); + + run_evict_excess_active(&mut world); + + let pressure = world.resource::<SimSpacePressure>(); + // active_count is set BEFORE eviction runs (it reads the pre-eviction count). + // The actual count changes via deferred commands, which apply after the system. + assert_eq!(pressure.active_count, 3, "pressure tracks pre-eviction count"); + } + + #[test] + fn scope_pinned_npcs_survive_eviction_at_scale() { + // Regression: evict_excess_active must never demote a ScopePinned NPC, + // even when many NPCs are over capacity (D-026, #97, #98). + // + // Setup: 85 Active NPCs (capacity = 80 → 5 must be evicted). + // - 10 are ScopePinned (must ALL remain ActiveSim after eviction). + // - 75 are unpinned (5 oldest are eviction targets; 70 survive). + // + // The Without<ScopePinned> query filter in evict_excess_active is the + // core invariant under test. This test fails immediately if that filter + // is removed or mis-applied. + let mut world = World::new(); + world.insert_resource(SimSpacePressure { + active_count: 0, + capacity: 80, + }); + + // Player at origin — all NPCs are within BACKGROUND_RADIUS. + world.spawn((PlayerCharacter, make_pos(0, 0))); + + // Spawn 10 ScopePinned NPCs. Give them the oldest ticks so they would + // be prime eviction candidates if Without<ScopePinned> were absent. + let pinned: Vec<Entity> = (0..10) + .map(|i| { + world + .spawn(( + Npc, + ActiveSim, + ScopePinned, + make_pos(5 + i, 0), + LastInteractionTick(i as u64), + )) + .id() + }) + .collect(); + + // Spawn 5 unpinned NPCs with old ticks — these are the actual eviction targets. + let unpinned_oldest: Vec<Entity> = (0..5) + .map(|i| { + world + .spawn(( + Npc, + ActiveSim, + make_pos(20 + i, 0), + LastInteractionTick(i as u64), + )) + .id() + }) + .collect(); + + // Spawn 70 unpinned NPCs with newer ticks — these survive. + for i in 0..70i32 { + world.spawn(( + Npc, + ActiveSim, + make_pos(30 + i, 0), + LastInteractionTick(100 + i as u64), + )); + } + + // Total: 10 pinned + 5 oldest-unpinned + 70 newer-unpinned = 85 active. + // cap = 80 → exactly 5 must be evicted. + run_evict_excess_active(&mut world); + + // Core invariant: ALL pinned entities remain ActiveSim. + for (i, &entity) in pinned.iter().enumerate() { + assert!( + world.get::<ActiveSim>(entity).is_some(), + "ScopePinned NPC {} must remain ActiveSim after eviction (D-026 #98)", + i + ); + assert!( + world.get::<BackgroundSim>(entity).is_none(), + "ScopePinned NPC {} must NOT be demoted to BackgroundSim", + i + ); + assert!( + world.get::<StateSaved>(entity).is_none(), + "ScopePinned NPC {} must NOT be demoted to StateSaved", + i + ); + } + + // Sanity: the 5 oldest unpinned were the ones evicted. + let evicted_count = unpinned_oldest + .iter() + .filter(|&&e| world.get::<ActiveSim>(e).is_none()) + .count(); + assert_eq!( + evicted_count, 5, + "exactly 5 unpinned NPCs (the oldest) should have been evicted to reach capacity" + ); + } + + // ----------------------------------------------------------------------- + // LastInteractionTick component tests (#97) + // ----------------------------------------------------------------------- + + #[test] + fn last_interaction_tick_defaults_to_zero() { + let tick = LastInteractionTick::default(); + assert_eq!(tick.0, 0); + } } diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index aad81a8c9..363344845 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -183,6 +183,7 @@ pub fn setup_gauntlet(app: &mut App) { profile, profile.initial_stance(), PlayerMoveCooldown::default(), + crate::simulation::pressure::CharacterPressure::default(), )) .id(); registry.register(player); diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 886224372..55275814c 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -66,7 +66,15 @@ fn snapshot_roundtrip_over_unix_socket() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 0e949d56b..75ad29a3d 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -52,7 +52,15 @@ fn snapshot_roundtrip_over_tcp() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; bridge diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs new file mode 100644 index 000000000..f3d2b72b1 --- /dev/null +++ b/server/tests/environmental_interaction.rs @@ -0,0 +1,449 @@ +//! Integration tests for basic environmental interaction (#246). +//! +//! Covers the acceptance criteria from the sprint briefing: +//! - Door toggle: player interacts with a Door, walkability flips; interacts again, flips back. +//! - Readable examine: Examine on a Readable entity returns non-empty text. +//! - Terminal interaction: Use on a Terminal emits TerminalInteracted event. +//! - DoorState persists in SaveStateV1.open_doors. + +use bevy_ecs::{prelude::*, schedule::Schedule, world::World}; +use settled_reach_server::{ + knowledge::{registry::EntityRegistry, registry::StableEntityId, types::StableId}, + npc::relationships::RelationshipGraph, + simulation::{ + examine::{ + process_examine_interaction, ExamineRequest, ExamineResultBuffer, ExamineText, + }, + interaction::{ + process_door_interaction, process_terminal_interaction, DoorInteractRequest, + DoorState, Interactable, ObjectType, TerminalInteractRequest, TerminalInteractedQueue, + }, + movement::{PlayerCharacter, TilePosition, WalkabilityMap}, + save_state::{SaveStateV1, SAVE_FORMAT_VERSION}, + time::{SimulationTime, TickRate}, + }, +}; +use settled_reach_server::knowledge::KnowledgeGraph; +use settled_reach_server::content::template::TemplateReferenceMap; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_world_with_walkability(width: i32, height: i32) -> World { + let mut world = World::new(); + world.insert_resource(WalkabilityMap::new(width, height, 1)); + world.init_resource::<EntityRegistry>(); + world +} + +fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity { + world + .spawn(( + PlayerCharacter, + TilePosition::new(x, y, 0), + )) + .id() +} + +fn spawn_door(world: &mut World, x: i32, y: i32, blocking_x: i32, blocking_y: i32) -> Entity { + world + .spawn(( + TilePosition::new(x, y, 0), + Interactable, + ObjectType::Door, + DoorState::new(TilePosition::new(blocking_x, blocking_y, 0)), + )) + .id() +} + +// --------------------------------------------------------------------------- +// Door behavior tests +// --------------------------------------------------------------------------- + +/// Acceptance: player interacts with Door, walkability flips; interacts again, flips back. +#[test] +fn door_toggle_flips_walkability_both_ways() { + let mut world = make_world_with_walkability(20, 20); + + // Block the door tile initially + world + .resource_mut::<WalkabilityMap>() + .set_walkable(&TilePosition::new(10, 5, 0), false); + + let player = spawn_player(&mut world, 10, 6); + let door = spawn_door(&mut world, 10, 6, 10, 5); // door tile at (10,5) + + let mut schedule = Schedule::default(); + schedule.add_systems(process_door_interaction); + + // First interaction: open the door + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: door }); + + schedule.run(&mut world); + + assert!( + world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)), + "after Open: blocking tile must become walkable" + ); + assert!( + world.get::<DoorState>(door).unwrap().is_open, + "DoorState.is_open must be true after opening" + ); + // Request should be consumed + assert!( + world.get::<DoorInteractRequest>(player).is_none(), + "DoorInteractRequest must be removed after processing" + ); + + // Second interaction: close the door + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: door }); + + schedule.run(&mut world); + + assert!( + !world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)), + "after Close: blocking tile must be impassable again" + ); + assert!( + !world.get::<DoorState>(door).unwrap().is_open, + "DoorState.is_open must be false after closing" + ); +} + +/// Door starts open: toggling closes it (walkability → blocked). +#[test] +fn door_starts_open_toggle_closes_it() { + let mut world = make_world_with_walkability(20, 20); + + // Start with door open (tile walkable, is_open = true) + let player = spawn_player(&mut world, 10, 6); + let door = world + .spawn(( + TilePosition::new(10, 6, 0), + Interactable, + ObjectType::Door, + DoorState { + is_open: true, + blocking_tile: TilePosition::new(10, 5, 0), + }, + )) + .id(); + + // Tile starts walkable (default map is all walkable) + assert!( + world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)), + "precondition: tile is walkable when door starts open" + ); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_door_interaction); + + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: door }); + + schedule.run(&mut world); + + assert!( + !world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)), + "toggling an open door must block the tile" + ); + assert!( + !world.get::<DoorState>(door).unwrap().is_open, + "DoorState.is_open must be false after closing an open door" + ); +} + +/// Missing DoorState on target: system logs warning and removes request without panic. +#[test] +fn door_interact_without_door_state_does_not_panic() { + let mut world = make_world_with_walkability(10, 10); + let player = spawn_player(&mut world, 5, 5); + let not_a_door = world.spawn(TilePosition::new(5, 6, 0)).id(); + + world + .entity_mut(player) + .insert(DoorInteractRequest { door_entity: not_a_door }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_door_interaction); + schedule.run(&mut world); + + // Should not panic; request removed + assert!( + world.get::<DoorInteractRequest>(player).is_none(), + "DoorInteractRequest must be consumed even when target lacks DoorState" + ); +} + +// --------------------------------------------------------------------------- +// Readable examine tests +// --------------------------------------------------------------------------- + +/// Acceptance: Examine on a Readable entity with ExamineText returns non-empty text. +#[test] +fn examine_readable_returns_authored_text() { + use settled_reach_server::knowledge::events::KnowledgeEventQueue; + + let mut world = World::new(); + world.init_resource::<KnowledgeEventQueue>(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<SimulationTime>(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ExamineResultBuffer::default(), + )) + .id(); + + let readable = world + .spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Readable, + ExamineText("A logistics manifest. Freight records dating back three cycles.".to_string()), + )) + .id(); + + world + .entity_mut(player) + .insert(ExamineRequest { target: readable }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_examine_interaction); + schedule.run(&mut world); + + let event = world + .get_mut::<ExamineResultBuffer>(player) + .unwrap() + .take(); + assert!( + event.is_some(), + "ExamineResultBuffer must contain a result after examining a Readable" + ); + let text = event.unwrap().text; + assert!( + !text.is_empty(), + "examine result text must be non-empty for a Readable entity" + ); + assert!( + text.contains("manifest") || text.contains("Freight") || text.contains("records"), + "text should match the authored ExamineText, got: '{text}'" + ); +} + +/// Examine on a Readable entity WITHOUT ExamineText returns a generic non-empty fallback. +#[test] +fn examine_readable_without_examine_text_returns_fallback() { + use settled_reach_server::knowledge::events::KnowledgeEventQueue; + + let mut world = World::new(); + world.init_resource::<KnowledgeEventQueue>(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<SimulationTime>(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ExamineResultBuffer::default(), + )) + .id(); + + // Readable but no ExamineText component + let readable = world + .spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Readable, + )) + .id(); + + world + .entity_mut(player) + .insert(ExamineRequest { target: readable }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_examine_interaction); + schedule.run(&mut world); + + let event = world + .get_mut::<ExamineResultBuffer>(player) + .unwrap() + .take(); + assert!( + event.is_some(), + "ExamineResultBuffer must contain a result even without ExamineText" + ); + let text = event.unwrap().text; + assert!(!text.is_empty(), "fallback text must be non-empty, got: '{text}'"); +} + +/// Examine out of range returns no result. +#[test] +fn examine_readable_out_of_range_returns_no_result() { + use settled_reach_server::knowledge::events::KnowledgeEventQueue; + + let mut world = World::new(); + world.init_resource::<KnowledgeEventQueue>(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<SimulationTime>(); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ExamineResultBuffer::default(), + )) + .id(); + + // Distance 8 > CLOSE_RANGE (2) + let readable = world + .spawn(( + TilePosition::new(5, 13, 0), + Interactable, + ObjectType::Readable, + ExamineText("Out of range text".to_string()), + )) + .id(); + + world + .entity_mut(player) + .insert(ExamineRequest { target: readable }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_examine_interaction); + schedule.run(&mut world); + + let event = world + .get_mut::<ExamineResultBuffer>(player) + .unwrap() + .take(); + assert!(event.is_none(), "examining an out-of-range Readable must not produce a result"); +} + +// --------------------------------------------------------------------------- +// Terminal interaction tests +// --------------------------------------------------------------------------- + +/// Use on a Terminal emits TerminalInteracted event. +#[test] +fn terminal_use_emits_terminal_interacted_event() { + let mut world = World::new(); + world.init_resource::<TerminalInteractedQueue>(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<SimulationTime>(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + let terminal = world + .spawn(( + TilePosition::new(5, 6, 0), + Interactable, + ObjectType::Terminal, + )) + .id(); + + // Register terminal in EntityRegistry so stable ID resolves + let terminal_sid = StableId(42); + world.entity_mut(terminal).insert(StableEntityId(terminal_sid)); + world.resource_mut::<EntityRegistry>().register_existing(terminal, terminal_sid); + + world + .entity_mut(player) + .insert(TerminalInteractRequest { terminal_entity: terminal }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_terminal_interaction); + schedule.run(&mut world); + + let queue = world.resource::<TerminalInteractedQueue>(); + assert_eq!(queue.events.len(), 1, "one TerminalInteracted event must be emitted"); + assert_eq!( + queue.events[0].terminal_id, terminal_sid, + "terminal_id must match the interacted terminal" + ); +} + +/// TerminalInteractRequest is removed after processing. +#[test] +fn terminal_interact_request_consumed_after_processing() { + let mut world = World::new(); + world.init_resource::<TerminalInteractedQueue>(); + world.init_resource::<EntityRegistry>(); + world.init_resource::<SimulationTime>(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + + let terminal = world.spawn(TilePosition::new(5, 6, 0)).id(); + + world + .entity_mut(player) + .insert(TerminalInteractRequest { terminal_entity: terminal }); + + let mut schedule = Schedule::default(); + schedule.add_systems(process_terminal_interaction); + schedule.run(&mut world); + + assert!( + world.get::<TerminalInteractRequest>(player).is_none(), + "TerminalInteractRequest must be removed after processing" + ); +} + +// --------------------------------------------------------------------------- +// SaveStateV1.open_doors field +// --------------------------------------------------------------------------- + +fn minimal_save() -> SaveStateV1 { + SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 0, + tick_rate: TickRate::Full, + seed: 42, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![], + template_references: TemplateReferenceMap::default(), + triangle_states: vec![], + open_doors: vec![], + } +} + +/// SaveStateV1.open_doors round-trips through MessagePack serialization. +#[test] +fn save_state_open_doors_roundtrip() { + let mut save = minimal_save(); + save.open_doors = vec![StableId(10), StableId(20), StableId(30)]; + + let bytes = save.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + + assert_eq!( + recovered.open_doors, + vec![StableId(10), StableId(20), StableId(30)], + "open_doors must round-trip through MessagePack" + ); +} + +/// Older save files (no open_doors field) deserialize without error. +/// The field defaults to empty vec via #[serde(default)]. +#[test] +fn save_state_open_doors_defaults_to_empty_on_old_saves() { + let save = minimal_save(); + assert!( + save.open_doors.is_empty(), + "open_doors must default to empty vec (backward compat with older saves)" + ); +} diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs new file mode 100644 index 000000000..78ec5bc07 --- /dev/null +++ b/server/tests/error_handling.rs @@ -0,0 +1,314 @@ +//! Error handling integration tests (#85). +//! +//! Tests the three error categories: +//! 1. Protocol errors: malformed input → SimError + server continues +//! 2. Desync detection: state_hash field populated in snapshots +//! 3. SimError wire format roundtrip + +use bevy_app::prelude::*; +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::tcp::TcpBridge; +use settled_reach_server::bridge::types::*; +use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning}; +use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; +use settled_reach_server::npc::relationships::TrustEventQueue; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; +use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState}; +use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::SimulationPlugin; +use std::io::{BufReader, BufWriter}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Barrier}; +use std::thread; + +/// Acceptance test (#85): send a malformed message mid-session, assert the +/// server emits a SimError in the next snapshot and continues running. +/// +/// Uses barriers to synchronize the server and client threads, ensuring +/// the malformed data arrives before the server's receive_inputs call. +#[test] +fn malformed_input_produces_sim_error_and_server_continues() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + // Barriers for tick synchronization between server and client. + // Each barrier is used once: client signals "data sent", server proceeds to tick. + let barrier_tick1 = Arc::new(Barrier::new(2)); + let barrier_tick2 = Arc::new(Barrier::new(2)); + let barrier_tick3 = Arc::new(Barrier::new(2)); + + let b1_server = Arc::clone(&barrier_tick1); + let b2_server = Arc::clone(&barrier_tick2); + let b3_server = Arc::clone(&barrier_tick3); + + // Server thread + let server_handle = thread::spawn(move || { + let bridge = TcpBridge::accept_on(listener).expect("accept connection"); + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.init_resource::<TrustEventQueue>(); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + MonologueState::default(), + )); + + // Tick 1: wait for client to send valid input, then process + b1_server.wait(); + app.update(); + assert!( + app.world().resource::<ServerRunning>().0, + "server should be running after tick 1" + ); + + // Tick 2: wait for client to send malformed input, then process + b2_server.wait(); + app.update(); + assert!( + app.world().resource::<ServerRunning>().0, + "server must continue running after malformed input" + ); + + // Tick 3: wait for client to send valid input, then process + b3_server.wait(); + app.update(); + assert!( + app.world().resource::<ServerRunning>().0, + "server should still be running after tick 3" + ); + }); + + // Client: connect and interact with synchronization + let stream = TcpStream::connect(server_addr).expect("client connect"); + let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); + let mut writer = BufWriter::new(stream); + + // --- Tick 1: send valid input --- + let valid_inputs = vec![PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }]; + let payload = rmp_serde::to_vec_named(&valid_inputs).expect("serialize"); + write_framed(&mut writer, &payload).expect("send valid input"); + barrier_tick1.wait(); // Signal: valid input sent + + // Read tick 1 snapshot + let snap1_bytes = read_framed(&mut reader) + .expect("read snapshot 1") + .expect("not EOF"); + let snap1: ObserverSnapshot = + rmp_serde::from_slice(&snap1_bytes).expect("deserialize snapshot 1"); + assert!( + snap1.sim_errors.is_empty(), + "no errors expected on tick 1" + ); + + // --- Tick 2: send malformed input (properly framed but garbage payload) --- + let garbage_payload: Vec<u8> = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xAB, 0xCD, 0xEF]; + write_framed(&mut writer, &garbage_payload).expect("send malformed input"); + barrier_tick2.wait(); // Signal: malformed input sent + + // Read tick 2 snapshot — should contain SimError + let snap2_bytes = read_framed(&mut reader) + .expect("read snapshot 2") + .expect("not EOF"); + let snap2: ObserverSnapshot = + rmp_serde::from_slice(&snap2_bytes).expect("deserialize snapshot 2"); + assert!( + !snap2.sim_errors.is_empty(), + "sim_errors must contain the protocol error from malformed input" + ); + assert_eq!( + snap2.sim_errors[0].kind, + SimErrorKind::ProtocolError, + "error kind must be ProtocolError" + ); + assert!( + snap2.sim_errors[0].message.contains("Malformed input frame") + || snap2.sim_errors[0].message.contains("Deserialization error"), + "error message should describe the deserialization failure, got: {}", + snap2.sim_errors[0].message, + ); + + // --- Tick 3: send valid input again — server must still work --- + let valid_inputs2 = vec![PlayerInput { + tick: 2, + action: PlayerAction::MoveSouth, + }]; + let payload2 = rmp_serde::to_vec_named(&valid_inputs2).expect("serialize"); + write_framed(&mut writer, &payload2).expect("send valid input after error"); + barrier_tick3.wait(); // Signal: valid input sent + + // Read tick 3 snapshot — no errors, server recovered + let snap3_bytes = read_framed(&mut reader) + .expect("read snapshot 3") + .expect("not EOF"); + let snap3: ObserverSnapshot = + rmp_serde::from_slice(&snap3_bytes).expect("deserialize snapshot 3"); + assert!( + snap3.sim_errors.is_empty(), + "no errors expected on tick 3 — server recovered" + ); + + // Clean up + drop(reader); + drop(writer); + server_handle.join().expect("server thread should not panic"); +} + +/// State hash is populated in every snapshot and is deterministic for same state. +#[test] +fn state_hash_populated_in_snapshot() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + let server_handle = thread::spawn(move || { + let bridge = TcpBridge::accept_on(listener).expect("accept connection"); + + let mut app = App::new(); + app.add_plugins(SimulationPlugin); + app.add_plugins(BridgePlugin); + app.add_plugins(KnowledgePlugin); + app.init_resource::<TrustEventQueue>(); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + MonologueState::default(), + )); + + // Run one tick + app.update(); + }); + + let stream = TcpStream::connect(server_addr).expect("client connect"); + let mut reader = BufReader::new(stream.try_clone().expect("clone for reader")); + let mut writer = BufWriter::new(stream); + + // Send empty input batch (no movement) + let inputs: Vec<PlayerInput> = vec![]; + let payload = rmp_serde::to_vec_named(&inputs).expect("serialize"); + write_framed(&mut writer, &payload).expect("send empty input"); + + // Read snapshot + let snap_bytes = read_framed(&mut reader) + .expect("read snapshot") + .expect("not EOF"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&snap_bytes).expect("deserialize snapshot"); + + assert!( + snap.state_hash.is_some(), + "state_hash must be populated in snapshot" + ); + assert_ne!( + snap.state_hash.unwrap(), + 0, + "state_hash should be a non-trivial hash value" + ); + + drop(reader); + drop(writer); + server_handle.join().expect("server thread should not panic"); +} + +/// SimError roundtrips through MessagePack serialization. +#[test] +fn sim_error_roundtrip() { + let error = SimError { + kind: SimErrorKind::ProtocolError, + message: "test protocol error".into(), + tick: 42, + }; + + let bytes = rmp_serde::to_vec_named(&error).expect("serialize SimError"); + let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize SimError"); + + assert_eq!(decoded.kind, SimErrorKind::ProtocolError); + assert_eq!(decoded.message, "test protocol error"); + assert_eq!(decoded.tick, 42); +} + +/// SimErrorKind::Panic variant roundtrips. +#[test] +fn sim_error_panic_variant_roundtrip() { + let error = SimError { + kind: SimErrorKind::Panic, + message: "simulation system panicked".into(), + tick: 100, + }; + + let bytes = rmp_serde::to_vec_named(&error).expect("serialize"); + let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.kind, SimErrorKind::Panic); + assert_eq!(decoded.message, "simulation system panicked"); + assert_eq!(decoded.tick, 100); +} + +/// Snapshot with sim_errors populates correctly through serialization. +#[test] +fn snapshot_with_sim_errors_roundtrips() { + use settled_reach_server::simulation::time::{DayPhase, TickRate}; + + let snapshot = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick: 10, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + blocked_entities: vec![], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: Some(0xDEADBEEF), + sim_errors: vec![ + SimError { + kind: SimErrorKind::ProtocolError, + message: "bad frame".into(), + tick: 10, + }, + ], + }; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.state_hash, Some(0xDEADBEEF)); + assert_eq!(decoded.sim_errors.len(), 1); + assert_eq!(decoded.sim_errors[0].kind, SimErrorKind::ProtocolError); + assert_eq!(decoded.sim_errors[0].message, "bad frame"); +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 86a7346a9..3582b4398 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -2,6 +2,7 @@ //! Run with: cargo test --test gen_fixtures -- --ignored use settled_reach_server::bridge::types::*; +use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; use std::path::Path; @@ -41,7 +42,15 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -227,7 +236,15 @@ fn generate_msgpack_fixtures() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; write_fixture( "snapshot_v2_full", @@ -277,6 +294,154 @@ fn generate_msgpack_fixtures() { write_fixture(name, &rmp_serde::to_vec_named(&input).unwrap()); } + // === #271 fixtures: named fixtures for cross-language Layer 1 testing === + + // snapshot_minimal: version=PROTOCOL_VERSION, tick=0, one Player entity, all optionals absent + let snapshot_minimal = fixture_snapshot( + 0, + vec![VisibleEntity { + entity_id: 1, + x: 0.0, + y: 0.0, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }], + ); + write_fixture( + "snapshot_minimal", + &rmp_serde::to_vec_named(&snapshot_minimal).unwrap(), + ); + + // snapshot_full: version=PROTOCOL_VERSION, tick=42, monologue + dialogue + inventory + POIs + KG dump + let snapshot_full = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick: 42, + game_time: GameTime { + day: 3, + time_of_day: 840, + day_phase: DayPhase::Evening, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::East, + player_stance: MovementStance::Walk, + player_inventory: vec![InventoryItem { + item_id: 7, + name: "Forged Customs Cert".to_string(), + slot: 0, + }], + entities: vec![VisibleEntity { + entity_id: 1, + x: 10.0, + y: 10.0, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: Some(MonologueEvent { + id: "test_monologue_001".to_string(), + text: "Something feels off about this place.".to_string(), + duration_seconds: 4.0, + }), + pending_recognitions: vec![PendingRecognitionWire { + entity_id: 7, + x: 12.0, + y: 8.0, + z: 0, + remaining_ticks: 3, + total_delay_ticks: 10, + }], + dialogue_response: Some(DialogueResponseEvent { + line_id: "kael_d_001".to_string(), + text: "We need to talk about the shipment.".to_string(), + speaker_entity_id: 99, + speaker_color_index: 2, + speaker_name: "Kael".to_string(), + }), + blocked_entities: vec![5, 6], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: Some(0xDEADBEEF), + poi_list: vec![PoiWire { + poi_id: "docking_bay_7".to_string(), + name: "Docking Bay 7".to_string(), + x: 50, + y: 30, + z: 0, + category: PoiCategory::Location, + }], + examine_result: Some(ExamineResultWire { + entity_id: 42, + text: "A smuggler, probably. The way they hold themselves.".to_string(), + confidence: KnowledgeConfidence::KnowsOf, + }), + save_result: None, + player_knowledge: Some(PlayerKnowledgeWire { + entities: vec![KnownEntityWire { + entity_id: 99, + name: "Kael".to_string(), + confidence: KnowledgeConfidence::KnowsDetails, + source: "DirectObservation".to_string(), + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + last_observed_tick: 40, + }], + facts: vec![KnownFactWire { + fact_id: "poi.docking_bay_7".to_string(), + confidence: KnowledgeConfidence::KnowsOf, + source: "DirectObservation".to_string(), + state: KnowledgeState::Active, + acquired_tick: 10, + }], + }), + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], + }; + write_fixture( + "snapshot_full", + &rmp_serde::to_vec_named(&snapshot_full).unwrap(), + ); + + // player_input_move: tick=1, MoveNorth + let input_move = PlayerInput { + tick: 1, + action: PlayerAction::MoveNorth, + }; + write_fixture( + "player_input_move", + &rmp_serde::to_vec_named(&input_move).unwrap(), + ); + + // player_input_interact: tick=2, Interact { target: 99, verb: "Talk" } + let input_interact = PlayerInput { + tick: 2, + action: PlayerAction::Interact { + target_entity_id: Some(99), + verb: Some("Talk".to_string()), + }, + }; + write_fixture( + "player_input_interact", + &rmp_serde::to_vec_named(&input_interact).unwrap(), + ); + + // malformed: intentionally truncated bytes — tests error handling in both Rust and GDScript + // 0x82 = fixmap with 2 entries, 0xa4 = fixstr of length 4 — incomplete map, no key/value follows + write_fixture("malformed", &[0x82u8, 0xa4u8]); + // === Boundary value fixtures (#472) === // 14 raw integer values at encoding format boundaries (Appendix C). // These are Rust-encoded MessagePack that GDScript must decode correctly. diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 1f4f346c8..b6a0e5cf3 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -2,6 +2,7 @@ "blocked_entities": [ 2 ], + "character_pressure": null, "conversation_ended": [], "conversation_events": [], "current_monologue": null, @@ -67,11 +68,14 @@ "player_facing": "North", "player_inventory": [], "player_stance": "Sprint", + "poi_list": [], "rng_seed": 42, "scan_events": [], "sound_events": [], + "state_hash": 14452262397297540338, "tick": 8, - "version": 13, + "triangle_crisis_events": [], + "version": 17, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs new file mode 100644 index 000000000..2770f5721 --- /dev/null +++ b/server/tests/information_boundaries.rs @@ -0,0 +1,319 @@ +//! Information boundary negative test suite (D-010, D-030, ticket #272). +//! +//! THE core asymmetric information claim: entity X cannot see what entity Y +//! knows, unless the observation system explicitly grants it. +//! +//! These are NEGATIVE tests — they assert that information does NOT cross +//! boundaries. Each test uses `assert!(x.is_none())` or equivalent absence +//! patterns, not just "test passed because nothing happened." +//! +//! ## Test layers (D-030) +//! +//! Layer 1 (pure unit, no ECS): +//! - `player_kg_has_no_passive_npc_leakage` — KG starts empty, stays empty +//! - `save_state_npc_kg_isolation` — per-NPC KG serialization isolation +//! - `snapshot_excludes_entities_outside_los` — FOV geometry excludes far tiles +//! +//! Layer 2 (minimal ECS world, no subprocess): +//! - `background_npc_kg_not_updated_by_active_tier_events` — tier boundary holds +//! +//! Spec references: D-010 (info boundaries), D-026 (tiers), D-030 (testability), +//! D-041 (knowledge graph), Q-029 (save format) + +use bevy_ecs::prelude::*; +use bevy_ecs::schedule::Schedule; + +use settled_reach_server::knowledge::events::{ + process_knowledge_events, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, +}; +use settled_reach_server::knowledge::{ + ContradictionDetectedQueue, EntityRegistry, KnowledgeGraph, +}; +use settled_reach_server::knowledge::types::StableId; +use settled_reach_server::npc::{Npc, SecretSeverity}; +use settled_reach_server::npc::relationships::RelationshipGraph; +use settled_reach_server::perception::query::{NaturalVision, PerceptionQuery}; +use settled_reach_server::simulation::movement::{TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::save_state::{NpcSaveState, SaveStateV1, SAVE_FORMAT_VERSION}; +use settled_reach_server::simulation::tier::{ActiveSim, BackgroundSim}; +use settled_reach_server::simulation::time::TickRate; +use settled_reach_server::bridge::types::FacingDirection; + +// =========================================================================== +// Layer 1 — Pure unit: no ECS world, no subprocess +// =========================================================================== + +/// IB-1 (Layer 1): A fresh KnowledgeGraph contains no entries for any entity. +/// +/// Core claim: player knowledge is never passively populated. The KG starts +/// empty and can only be written by `observe_entity()`, `record_knowledge()`, +/// or knowledge events processed by `process_knowledge_events`. Simply +/// existing in the simulation world does not leak an NPC's existence into +/// the player's knowledge graph. +/// +/// Spec reference: D-010 principle 2 (information boundaries as first-class system) +#[test] +fn player_kg_has_no_passive_npc_leakage() { + let player_kg = KnowledgeGraph::new(); + let npc_id = StableId(42); + + // Negative assertion: a freshly created KG contains no entity references. + assert!( + player_kg.entities.get(&npc_id).is_none(), + "IB-1: fresh KnowledgeGraph must not contain any entity (passive leakage — D-010 principle 2)" + ); + assert!( + player_kg.is_empty(), + "IB-1: KnowledgeGraph::new() must be completely empty" + ); + + // Negative assertion: spawning a bare ECS entity doesn't populate a KG. + // The knowledge graph is a component, not a global shared resource. + let mut world = World::new(); + let player = world + .spawn(KnowledgeGraph::new()) + .id(); + + // Spawn an NPC in the same world — no observation system runs. + let _npc = world.spawn((Npc, TilePosition::new(50, 50, 0))).id(); + + // Player's KG must be empty regardless of NPCs existing nearby. + let kg = world.get::<KnowledgeGraph>(player).unwrap(); + assert!( + kg.entities.get(&npc_id).is_none(), + "IB-1: spawning an NPC in the world must not passively populate the player's KG" + ); + assert!( + kg.is_empty(), + "IB-1: player KG must stay empty until an observation system explicitly populates it" + ); +} + +/// IB-2 (Layer 1): FOV geometry excludes positions beyond the vision range. +/// +/// The observer snapshot system (compute_observer_snapshot) includes entities +/// by testing whether their tile position is in `VisibilityGeometry.visible_positions`. +/// This test verifies that the FOV computation — the upstream source of that set — +/// correctly excludes positions far from the observer, so no entity outside LOS +/// can ever appear in the snapshot. +/// +/// Spec reference: D-010 principle 2, D-011 (symmetric shadowcasting), D-030 Layer 1 +#[test] +fn snapshot_excludes_entities_outside_los() { + // All-walkable 100×100 map at z=0 — no walls to cast shadows. + let walkability = WalkabilityMap::new(100, 100, 1); + let observer_pos = TilePosition::new(5, 5, 0); + let facing = FacingDirection::North; + + let geometry = NaturalVision.compute_geometry(&observer_pos, facing, &walkability); + + // --- Far entity: 45 tiles away, well outside FOV range (~12 tiles) --- + let far_npc_pos = TilePosition::new(50, 5, 0); + assert!( + !geometry.visible_positions.contains(&(far_npc_pos.x, far_npc_pos.y)), + "IB-2: entity at {:?} (45 tiles from observer) must NOT be in FOV — \ + observer snapshot would exclude this entity (fog of perception, D-010 principle 2)", + far_npc_pos + ); + + // --- Sanity check: the observer's own position is visible --- + assert!( + geometry.visible_positions.contains(&(observer_pos.x, observer_pos.y)), + "IB-2 sanity: observer's own position must always be in the FOV set" + ); + + // --- Additional sanity: an immediately adjacent tile (1 step) is visible --- + let adjacent_pos = TilePosition::new(6, 5, 0); + assert!( + geometry.visible_positions.contains(&(adjacent_pos.x, adjacent_pos.y)), + "IB-2 sanity: tile immediately adjacent to observer must be visible" + ); +} + +/// IB-4 (Layer 1): NPC save states do not bleed each other's KnowledgeGraphs. +/// +/// `SaveStateV1.npc_states` is a flat `Vec<NpcSaveState>`. Each `NpcSaveState` +/// has its own optional `knowledge_graph: Option<KnowledgeGraph>`. After a +/// serialise → deserialise roundtrip: +/// - NPC_A's `NpcSaveState.knowledge_graph` contains ONLY NPC_A's own KG. +/// - NPC_B's `NpcSaveState.knowledge_graph` is `None` (Background tier, +/// no KG carried) — it must not be overwritten by NPC_A's KG data. +/// +/// Spec reference: D-010 principle 2, D-026 (tier serialization), Q-029 (save format) +#[test] +fn save_state_npc_kg_isolation() { + let npc_a_id = StableId(1); + let npc_b_id = StableId(2); + + // NPC_A (Active tier) carries a KG that has observed NPC_B. + let mut npc_a_kg = KnowledgeGraph::new(); + // NPC_A has observed NPC_B at some position — this puts NPC_B in NPC_A's KG. + let _ = npc_a_kg.observe_entity(npc_b_id, TilePosition::new(10, 10, 0), 5); + + let npc_a_state = NpcSaveState { + stable_id: npc_a_id, + position: TilePosition::new(5, 5, 0), + secret_severity: SecretSeverity::Minor, + relationships: None, + current_stress: 0, + tolerance_threshold: 20, + contentment: 50, + knowledge_graph: Some(npc_a_kg), // Active NPC carries KG + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + template_ownership: None, + }; + + // NPC_B (Background tier) does not carry a KG. + let npc_b_state = NpcSaveState { + stable_id: npc_b_id, + position: TilePosition::new(20, 20, 0), + secret_severity: SecretSeverity::Minor, + relationships: None, + current_stress: 0, + tolerance_threshold: 20, + contentment: 50, + knowledge_graph: None, // Background NPC carries no KG + want: None, + secret: None, + routine: None, + information_inventory: None, + personality_traits: None, + tell_system: None, + skill_set: None, + combat_capability: None, + mood_state: None, + job_performance: None, + template_ownership: None, + }; + + let save = SaveStateV1 { + format_version: SAVE_FORMAT_VERSION, + tick: 10, + tick_rate: TickRate::Full, + seed: 42, + player_knowledge: KnowledgeGraph::new(), + relationship_graph: RelationshipGraph::new(), + npc_states: vec![npc_a_state, npc_b_state], + template_references: Default::default(), + triangle_states: vec![], + open_doors: vec![], + }; + + // Roundtrip: serialize → deserialize. + let bytes = save.to_bytes().expect("IB-4: serialize SaveStateV1"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("IB-4: deserialize SaveStateV1"); + + // --- Negative assertion: NPC_B's state must NOT contain a KnowledgeGraph --- + let npc_b_recovered = recovered + .npc_states + .iter() + .find(|s| s.stable_id == npc_b_id) + .expect("IB-4: NPC_B must be present in recovered npc_states"); + + assert!( + npc_b_recovered.knowledge_graph.is_none(), + "IB-4: NPC_B's recovered state must not contain a KnowledgeGraph — \ + serialization must not bleed NPC_A's KG data into NPC_B's entry (D-010 principle 2)" + ); + + // --- Sanity: NPC_A's state must contain its own KG (not lost in roundtrip) --- + let npc_a_recovered = recovered + .npc_states + .iter() + .find(|s| s.stable_id == npc_a_id) + .expect("IB-4: NPC_A must be present in recovered npc_states"); + + let kg = npc_a_recovered + .knowledge_graph + .as_ref() + .expect("IB-4: NPC_A's KG must survive roundtrip"); + + // NPC_A's KG entry for NPC_B is NPC_A's OBSERVATION DATA (where NPC_A saw NPC_B). + // This is not NPC_B's own KG — it's NPC_A's record of NPC_B's position. + assert!( + kg.entities.get(&npc_b_id).is_some(), + "IB-4 sanity: NPC_A's KG should still contain its observation of NPC_B after roundtrip" + ); +} + +// =========================================================================== +// Layer 2 — Minimal ECS world (no subprocess) +// =========================================================================== + +/// IB-3 (Layer 2): `process_knowledge_events` only modifies the observer entity. +/// +/// Background-tier NPC KnowledgeGraphs must not be modified when Active-tier +/// events are processed. The `process_knowledge_events` system routes events +/// via `event.observer` (an ECS Entity handle) — only the targeted entity's KG +/// is written. This test confirms that a Background-tier NPC, not named in any +/// event's `observer` field, has its KG left completely unchanged. +/// +/// Spec reference: D-010 principle 2, D-026 (tier boundary), D-030 Layer 2 +#[test] +fn background_npc_kg_not_updated_by_active_tier_events() { + let mut world = World::new(); + + // Required resources for process_knowledge_events. + world.init_resource::<KnowledgeEventQueue>(); + world.init_resource::<ContradictionDetectedQueue>(); + world.init_resource::<EntityRegistry>(); + + // Active-tier NPC: will be the observer in the knowledge event. + let active_npc = world + .spawn((Npc, ActiveSim, KnowledgeGraph::new())) + .id(); + + // Background-tier NPC: must NOT be affected. + let background_npc = world + .spawn((Npc, BackgroundSim, KnowledgeGraph::new())) + .id(); + + // A separate "observed" entity (the target of the DirectObservation). + // Register it in the EntityRegistry so process_knowledge_events can resolve its StableId. + let observed_entity = world.spawn_empty().id(); + { + let mut registry = world.resource_mut::<EntityRegistry>(); + registry.register(observed_entity); + } + + // Push a DirectObservation event targeting only the Active NPC as observer. + // The Background NPC is not mentioned anywhere in this event. + world + .resource_mut::<KnowledgeEventQueue>() + .push(KnowledgeEvent { + observer: active_npc, + tick: 1, + event_type: KnowledgeEventType::DirectObservation { + target: observed_entity, + position: TilePosition::new(5, 5, 0), + }, + }); + + // Run the knowledge event processing system. + let mut schedule = Schedule::default(); + schedule.add_systems(process_knowledge_events); + schedule.run(&mut world); + + // --- Negative assertion: Background NPC's KG must be completely unchanged --- + let bg_kg = world + .get::<KnowledgeGraph>(background_npc) + .expect("IB-3: BackgroundSim NPC must still have KnowledgeGraph component"); + + assert!( + bg_kg.is_empty(), + "IB-3: Background-tier NPC KG must not be modified by Active-tier events. \ + process_knowledge_events must only update the event.observer entity (D-026 tier boundary, \ + D-010 principle 2). Found {} entity entries and {} fact entries.", + bg_kg.entity_count(), + bg_kg.fact_count() + ); +} diff --git a/server/tests/integration/mod.rs b/server/tests/integration/mod.rs new file mode 100644 index 000000000..bf630cd42 --- /dev/null +++ b/server/tests/integration/mod.rs @@ -0,0 +1,60 @@ +//! Layer 3 integration test entry point (D-030, ticket #200). +//! +//! ## Three-layer test architecture (D-030 sub-decision 3) +//! +//! ```text +//! Layer 1 — Fixture-based serialization (FAST, run on every edit) +//! Scope: Pure unit tests. No ECS world. No subprocess. +//! Tools: Rust #[test] + data structures directly. +//! Speed: <1ms each. +//! Files: tests/serialization.rs, tests/information_boundaries.rs (Layer 1 tests), +//! #[cfg(test)] mod tests within src/ modules +//! +//! Layer 2 — Mock subprocess / minimal ECS world (MEDIUM, run on every PR) +//! Scope: Minimal bevy App or World. Real systems, no real subprocess. +//! IPC roundtrip over Unix socket without spawning the binary. +//! Tools: bevy_ecs World + Schedule, or LocalBridge with in-process simulation. +//! Speed: 1ms–100ms each. +//! Files: tests/bridge_ipc.rs, tests/bridge_tcp.rs, +//! tests/information_boundaries.rs (Layer 2 tests), +//! tests/determinism.rs, tests/movement.rs, tests/smoke.rs +//! +//! Layer 3 — Real subprocess integration (SLOW, run daily / pre-merge) +//! Scope: Full binary spawned as a child process. No mocks. Real IPC. +//! Exercises the complete path: spawn → handshake → tick → snapshot. +//! Tools: std::process::Command, TcpStream. +//! Speed: 1s–15s each (process startup dominates). +//! Files: tests/layer3.rs, tests/integration/ (this module) +//! ``` +//! +//! ## Layer 3 test guidelines +//! +//! - Always set a deadline for server startup (`LISTEN_TIMEOUT`). +//! - Always kill the child process in teardown (even on test failure — use a +//! RAII guard or drop the handle at end of test). +//! - Use `--port 0` to get a kernel-assigned port; parse `LISTENING:{port}` from +//! stdout to obtain the actual port. +//! - Serialize `PlayerInput` via `rmp_serde`, frame with `bridge::framing::write_framed`. +//! - Deserialize `ObserverSnapshot` via `rmp_serde` after `bridge::framing::read_framed`. +//! +//! Spec reference: D-030 (testability architecture), D-020 (subprocess IPC protocol) + +// --------------------------------------------------------------------------- +// Stub: Layer 3 startup smoke test +// --------------------------------------------------------------------------- + +/// Placeholder for future Layer 3 tests that require full subprocess setup. +/// +/// Non-blocking tests that exercise the simulation binary end-to-end live in +/// `tests/layer3.rs`. This module is the organisational entry point for tests +/// that exercise multi-message Layer 3 scenarios (multi-tick sequences, +/// save/load roundtrip over IPC, protocol version negotiation). +/// +/// See `tests/layer3.rs::server_subprocess_sends_snapshot_on_connect` for the +/// canonical Layer 3 pattern. +#[test] +fn layer3_module_entry_point_placeholder() { + // This test exists to verify the integration module compiles and is + // discovered by cargo test. Real Layer 3 scenario tests replace this. + // D-030 Layer 3 stubs are acceptable until the IPC handshake (#555) lands. +} diff --git a/server/tests/ipc_bench.rs b/server/tests/ipc_bench.rs new file mode 100644 index 000000000..4f9ffee6d --- /dev/null +++ b/server/tests/ipc_bench.rs @@ -0,0 +1,199 @@ +//! IPC round-trip latency benchmark (#342, D-020) +//! +//! Measures end-to-end latency from client send (write_framed) to client receive +//! (read_framed) over the real subprocess IPC channel. Reports p50/p95/p99. +//! +//! Latency budget: p99 must be <= 5ms (D-020: "~1-5ms serialization latency per tick"). +//! +//! Run with: +//! cargo test --release --test ipc_bench -- --ignored --nocapture +//! +//! Output: IPC_BENCH_RESULT:{json} on a single line for tooling to parse. +//! +//! +//! Spec references: D-020 (subprocess IPC, 5ms budget), D-030 (Layer 3) + +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::types::*; +use std::io::{BufRead, BufReader, BufWriter}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Number of warmup round-trips before timing begins. +const WARMUP_ROUNDS: usize = 10; + +/// Number of timed round-trips (N in the spec). +const MEASURE_ROUNDS: usize = 100; + +/// Latency threshold (p99 must be below this). D-020: "~1-5ms per tick". +const THRESHOLD_MS: f64 = 5.0; + +/// Timeout for the server to emit LISTENING:{port} on stdout. +const LISTEN_TIMEOUT: Duration = Duration::from_secs(15); + +/// Timeout per round-trip read. +const ROUND_TRIP_TIMEOUT: Duration = Duration::from_secs(5); + +fn percentile(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let idx = ((sorted.len() - 1) as f64 * p).floor() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +#[test] +#[ignore] +fn ipc_round_trip_latency() { + // 1. Spawn server binary with --test-mode --port 0 + let server_bin = env!("CARGO_BIN_EXE_settled-reach-server"); + let mut child = Command::new(server_bin) + .args(["--test-mode", "--port", "0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn server binary"); + + let stdout = child.stdout.take().expect("stdout not captured"); + let mut stdout_reader = BufReader::new(stdout); + + // 2. Parse LISTENING:{port} from stdout + let port = { + let deadline = Instant::now() + LISTEN_TIMEOUT; + let mut line = String::new(); + loop { + line.clear(); + match stdout_reader.read_line(&mut line) { + Ok(0) => panic!("server stdout closed before LISTENING signal"), + Ok(_) => { + let trimmed = line.trim(); + if let Some(port_str) = trimmed.strip_prefix("LISTENING:") { + break port_str + .parse::<u16>() + .unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e)); + } + } + Err(e) => panic!("failed to read server stdout: {}", e), + } + assert!( + Instant::now() < deadline, + "timed out waiting for LISTENING signal" + ); + } + }; + + // 3. Connect via TCP + let addr = format!("127.0.0.1:{}", port); + let stream = TcpStream::connect(&addr) + .unwrap_or_else(|e| panic!("failed to connect to {}: {}", addr, e)); + stream + .set_read_timeout(Some(ROUND_TRIP_TIMEOUT)) + .expect("set read timeout"); + + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut writer = BufWriter::new(stream); + + // 4. Handshake: read and validate HandshakeMessage before timing (#555/#556). + // Server sends HandshakeMessage { protocol_version } as the very first framed message. + let handshake_bytes = read_framed(&mut reader) + .expect("read handshake") + .expect("server closed before sending HandshakeMessage"); + let handshake: HandshakeMessage = + rmp_serde::from_slice(&handshake_bytes).expect("deserialize HandshakeMessage"); + assert_eq!( + handshake.protocol_version, + PROTOCOL_VERSION, + "handshake version mismatch: server={}, client={}", + handshake.protocol_version, + PROTOCOL_VERSION + ); + + let make_input = |tick: u64| PlayerInput { + tick, + action: PlayerAction::MoveNorth, + }; + + let mut round_trip_ms: Vec<f64> = Vec::with_capacity(WARMUP_ROUNDS + MEASURE_ROUNDS); + + // 5. Warmup rounds (not timed) + for tick in 0..WARMUP_ROUNDS as u64 { + let payload = + rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput"); + write_framed(&mut writer, &payload).expect("send warmup input"); + let _ = read_framed(&mut reader) + .expect("read warmup snapshot") + .expect("server closed during warmup"); + } + + // 6. Timed measurement rounds + for tick in WARMUP_ROUNDS as u64..(WARMUP_ROUNDS + MEASURE_ROUNDS) as u64 { + let payload = + rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput"); + + let t_send = Instant::now(); + write_framed(&mut writer, &payload).expect("send timed input"); + let response = read_framed(&mut reader) + .expect("read timed snapshot") + .expect("server closed during measurement"); + let elapsed_ms = t_send.elapsed().as_secs_f64() * 1000.0; + + // Verify we received a valid snapshot (not just noise) + let _snapshot: ObserverSnapshot = + rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); + + round_trip_ms.push(elapsed_ms); + } + + // 7. Clean up + drop(reader); + drop(writer); + let exit_deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if Instant::now() > exit_deadline { + child.kill().ok(); + child.wait().ok(); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + child.kill().ok(); + break; + } + } + } + + // 8. Compute percentiles + let mut sorted = round_trip_ms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + let p50 = percentile(&sorted, 0.50); + let p95 = percentile(&sorted, 0.95); + let p99 = percentile(&sorted, 0.99); + let passed = p99 <= THRESHOLD_MS; + + let result = serde_json::json!({ + "p50_ms": (p50 * 100.0).round() / 100.0, + "p95_ms": (p95 * 100.0).round() / 100.0, + "p99_ms": (p99 * 100.0).round() / 100.0, + "threshold_ms": THRESHOLD_MS, + "passed": passed, + "rounds": MEASURE_ROUNDS, + }); + + println!( + "IPC_BENCH_RESULT:{}", + serde_json::to_string(&result).unwrap() + ); + + // Fail the test if we exceed the latency budget + assert!( + passed, + "IPC latency budget exceeded: p99={:.2}ms > threshold={}ms", + p99, THRESHOLD_MS + ); +} diff --git a/server/tests/layer3.rs b/server/tests/layer3.rs index 07cc6eaf1..fc8524a1b 100644 --- a/server/tests/layer3.rs +++ b/server/tests/layer3.rs @@ -72,7 +72,19 @@ fn server_subprocess_sends_snapshot_on_connect() { let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader")); let mut writer = BufWriter::new(stream); - // 4. Send one PlayerInput (idle tick 0) + // 4. Read the protocol handshake (first framed message, #555) + let handshake_frame = read_framed(&mut reader) + .expect("read handshake frame") + .expect("server closed connection before sending handshake"); + let handshake: HandshakeMessage = + rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage"); + assert_eq!( + handshake.protocol_version, PROTOCOL_VERSION, + "handshake protocol_version mismatch: got {}, expected {}", + handshake.protocol_version, PROTOCOL_VERSION + ); + + // 5. Send one PlayerInput (idle tick 0) let inputs = vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth, @@ -80,14 +92,14 @@ fn server_subprocess_sends_snapshot_on_connect() { let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput"); write_framed(&mut writer, &payload).expect("send PlayerInput to server"); - // 5. Read one ObserverSnapshot + // 6. Read one ObserverSnapshot let response = read_framed(&mut reader) .expect("read snapshot frame") .expect("server closed connection before sending snapshot"); let snapshot: ObserverSnapshot = rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); - // 6. Assert protocol correctness (D-020) + // 7. Assert protocol correctness (D-020) assert_eq!( snapshot.version, PROTOCOL_VERSION, "protocol version mismatch: got {}, expected {}", @@ -105,7 +117,7 @@ fn server_subprocess_sends_snapshot_on_connect() { .any(|e| matches!(e.kind, EntityKind::Player)); assert!(has_player, "snapshot must contain a Player entity"); - // 7. Clean up: drop connection so the server exits its game loop + // 8. Clean up: drop connection so the server exits its game loop drop(reader); drop(writer); diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 48af4ebdb..959f93d57 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -30,7 +30,15 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -115,6 +123,12 @@ fn all_player_action_variants_roundtrip() { target_entity_id: 42, response_id: "kael-davan_d_001".to_string(), }, + PlayerAction::SaveGame { + path: "/tmp/test.msgpack".to_string(), + }, + PlayerAction::LoadGame { + path: "/tmp/test.msgpack".to_string(), + }, ]; for action in actions { @@ -169,13 +183,15 @@ fn all_fixtures_deserialize() { } else if name.starts_with("input_batch") { rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); - } else if name.starts_with("input") { + } else if name.starts_with("input") || name.starts_with("player_input") { rmp_serde::from_slice::<PlayerInput>(&bytes) .unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e)); } else if name.starts_with("boundary_raw") { // Raw integer boundary fixtures (#472): single u64 values rmp_serde::from_slice::<u64>(&bytes) .unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e)); + } else if name == "malformed" { + // Intentionally truncated — skip deserialization check, error handling tested elsewhere } else { panic!( "unknown fixture naming convention: {} — add a deserialization branch for this prefix", @@ -275,7 +291,15 @@ fn snapshot_v2_fields_roundtrip() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -330,7 +354,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 13, + PROTOCOL_VERSION, 17, "bump this assertion when protocol version changes" ); } @@ -374,7 +398,15 @@ fn all_facing_direction_variants_roundtrip() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, + character_pressure: None, rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); @@ -1418,8 +1450,8 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { let decoded: ObserverSnapshot = serde_json::from_value(minimal_json).expect("minimal JSON must deserialize"); - // Version and required fields present - assert_eq!(decoded.version, PROTOCOL_VERSION); + // Version matches what was in the wire + assert_eq!(decoded.version, 13); assert_eq!(decoded.tick, 42); assert_eq!(decoded.entities.len(), 1); @@ -1437,6 +1469,19 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { decoded.follow_state.is_none(), "follow_state must default to None when absent from wire" ); + // v14 fields default correctly when absent from older wire format + assert!( + decoded.poi_list.is_empty(), + "poi_list must default to empty when absent from wire" + ); + assert!( + decoded.examine_result.is_none(), + "examine_result must default to None when absent from wire" + ); + assert!( + decoded.player_knowledge.is_none(), + "player_knowledge must default to None when absent from wire" + ); } /// A snapshot with version != PROTOCOL_VERSION can be detected by checking @@ -1614,3 +1659,142 @@ fn nearby_interaction_object_type_roundtrip() { Some(ObjectType::Container) ); } + +// ============================================================ +// #271: Named fixture validation tests (D-030 Layer 1) +// +// These tests read the committed .msgpack files and assert specific field +// values. They serve as the Rust side of cross-language verification — the +// same fixtures are decoded by client/tests/test_ipc_fixtures.gd. +// ============================================================ + +fn read_named_fixture(name: &str) -> Vec<u8> { + let path = format!("../client/tests/fixtures/msgpack/{}.msgpack", name); + fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture '{}': {}", name, e)) +} + +#[test] +fn fixture_snapshot_minimal_fields() { + let bytes = read_named_fixture("snapshot_minimal"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal"); + + assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); + assert_eq!(snap.tick, 0, "tick should be 0"); + assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity"); + assert_eq!(snap.entities[0].entity_id, 1); + assert!( + matches!(snap.entities[0].kind, EntityKind::Player), + "entity should be Player kind" + ); + assert!(snap.current_monologue.is_none(), "no monologue in minimal"); + assert!(snap.dialogue_response.is_none(), "no dialogue in minimal"); + assert!(snap.player_inventory.is_empty(), "no inventory in minimal"); + assert!(snap.poi_list.is_empty(), "no POIs in minimal"); + assert!(snap.player_knowledge.is_none(), "no KG in minimal"); +} + +#[test] +fn fixture_snapshot_full_fields() { + let bytes = read_named_fixture("snapshot_full"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full"); + + assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); + assert_eq!(snap.tick, 42, "tick should be 42"); + + // Monologue + let monologue = snap.current_monologue.as_ref().expect("monologue absent"); + assert_eq!(monologue.id, "test_monologue_001"); + assert_eq!( + monologue.text, + "Something feels off about this place." + ); + + // Dialogue + let dialogue = snap.dialogue_response.as_ref().expect("dialogue absent"); + assert_eq!(dialogue.speaker_entity_id, 99); + assert_eq!(dialogue.speaker_name, "Kael"); + + // Inventory + assert_eq!(snap.player_inventory.len(), 1); + assert_eq!(snap.player_inventory[0].name, "Forged Customs Cert"); + + // POIs + assert_eq!(snap.poi_list.len(), 1); + assert_eq!(snap.poi_list[0].poi_id, "docking_bay_7"); + + // Examine result + let examine = snap.examine_result.as_ref().expect("examine_result absent"); + assert_eq!(examine.entity_id, 42); + + // Player knowledge + let kg = snap.player_knowledge.as_ref().expect("player_knowledge absent"); + assert_eq!(kg.entities.len(), 1); + assert_eq!(kg.entities[0].name, "Kael"); + assert_eq!(kg.facts.len(), 1); + assert_eq!(kg.facts[0].fact_id, "poi.docking_bay_7"); + + // RNG seed + assert_eq!(snap.rng_seed, Some(0xDEADBEEF)); + + // Pending recognitions + assert_eq!(snap.pending_recognitions.len(), 1); + assert_eq!(snap.pending_recognitions[0].entity_id, 7); + + // Blocked entities + assert_eq!(snap.blocked_entities, vec![5u64, 6]); +} + +#[test] +fn fixture_player_input_move_fields() { + let bytes = read_named_fixture("player_input_move"); + let input: PlayerInput = + rmp_serde::from_slice(&bytes).expect("deserialize player_input_move"); + + assert_eq!(input.tick, 1, "tick should be 1"); + assert!( + matches!(input.action, PlayerAction::MoveNorth), + "action should be MoveNorth" + ); +} + +#[test] +fn fixture_player_input_interact_fields() { + let bytes = read_named_fixture("player_input_interact"); + let input: PlayerInput = + rmp_serde::from_slice(&bytes).expect("deserialize player_input_interact"); + + assert_eq!(input.tick, 2, "tick should be 2"); + match &input.action { + PlayerAction::Interact { + target_entity_id, + verb, + } => { + assert_eq!(*target_entity_id, Some(99u64), "target_entity_id should be Some(99)"); + assert_eq!( + verb.as_deref(), + Some("Talk"), + "verb should be Some(\"Talk\")" + ); + } + other => panic!("expected Interact, got {:?}", other), + } +} + +#[test] +fn fixture_malformed_fails_deserialization() { + let bytes = read_named_fixture("malformed"); + // Intentionally truncated — must NOT deserialize as ObserverSnapshot + let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes); + assert!( + result.is_err(), + "malformed fixture should fail to deserialize as ObserverSnapshot" + ); + // Also must NOT deserialize as PlayerInput + let result2 = rmp_serde::from_slice::<PlayerInput>(&bytes); + assert!( + result2.is_err(), + "malformed fixture should fail to deserialize as PlayerInput" + ); +} diff --git a/server/tests/template_instantiation.rs b/server/tests/template_instantiation.rs new file mode 100644 index 000000000..86ae6bc93 --- /dev/null +++ b/server/tests/template_instantiation.rs @@ -0,0 +1,221 @@ +//! End-to-end tests for the template instantiation engine (#161). +//! +//! Verifies the full pipeline: +//! load YAML → validate → spawn NPCs → generate triangles → lifecycle +//! +//! Spec refs: +//! - D-023: three-tier content model +//! - D-024: 10-axis NPC model, minimum 2 triangles per social site +//! - D-025: social site as atomic template unit, single-ownership +//! - D-010: determinism (same seed → same layout) + +use std::path::PathBuf; + +use bevy_ecs::prelude::*; +use settled_reach_server::{ + content::{ + instantiation::{ + instantiate_template, load_template_from_file, unload_template, + ActiveTemplateInstances, + }, + template::{TemplateId, TemplateOwnership, TriangleState}, + }, + knowledge::{registry::EntityRegistry, StableEntityId}, + simulation::rng::SimRng, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn templates_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates") +} + +fn make_test_world() -> World { + let mut world = World::new(); + world.init_resource::<EntityRegistry>(); + world +} + +// --------------------------------------------------------------------------- +// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161) +// --------------------------------------------------------------------------- + +#[test] +fn template_instantiation_end_to_end_logistics_hub() { + let path = templates_dir().join("logistics-hub.yaml"); + let template_def = + load_template_from_file(&path).expect("logistics-hub.yaml must load and parse"); + + let mut world = make_test_world(); + let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); + let mut rng = SimRng::new(42); + + let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng) + .expect("logistics-hub must instantiate without validation errors"); + + // --- NPCs: all 4 role slots filled --- + assert_eq!( + instance.npc_entities.len(), + 4, + "logistics-hub has 4 role slots — 4 NPC entities expected" + ); + + // --- TemplateOwnership on every NPC --- + let expected_roles = [ + "logistics-manager", + "dock-worker", + "ring-contact", + "security-guard", + ]; + let mut seen_roles: Vec<String> = Vec::new(); + for &entity in &instance.npc_entities { + let ownership = world + .get::<TemplateOwnership>(entity) + .expect("every spawned NPC must have TemplateOwnership"); + assert_eq!( + ownership.template_id, template_id, + "TemplateOwnership.template_id must match the instantiated template" + ); + let role = &ownership.role_id.0; + assert!( + expected_roles.contains(&role.as_str()), + "unexpected role '{}' — not in logistics-hub role list", + role, + ); + seen_roles.push(role.clone()); + } + // Every role slot must appear exactly once. + for role in &expected_roles { + assert_eq!( + seen_roles.iter().filter(|r| r.as_str() == *role).count(), + 1, + "role '{}' must appear exactly once", + role, + ); + } + + // --- 2+ TriangleState entities (D-024 minimum) --- + assert!( + instance.triangle_entities.len() >= 2, + "logistics-hub must produce at least 2 TriangleState entities (D-024), got {}", + instance.triangle_entities.len(), + ); + for &entity in &instance.triangle_entities { + assert!( + world.get::<TriangleState>(entity).is_some(), + "triangle entity {:?} must carry a TriangleState component", + entity, + ); + } + + // --- Instance registered in ActiveTemplateInstances --- + let active = world.resource::<ActiveTemplateInstances>(); + assert!( + active.get(template_id).is_some(), + "instantiated template must be tracked in ActiveTemplateInstances", + ); +} + +// --------------------------------------------------------------------------- +// Lifecycle: unload despawns all entities +// --------------------------------------------------------------------------- + +#[test] +fn template_instantiation_unload_despawns_entities() { + let path = templates_dir().join("logistics-hub.yaml"); + let template_def = load_template_from_file(&path).expect("must parse"); + + let mut world = make_test_world(); + let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub"); + let mut rng = SimRng::new(99); + + let instance = + instantiate_template(&mut world, &template_def, template_id, 99, &mut rng) + .expect("must instantiate"); + + let all_entities: Vec<Entity> = instance + .npc_entities + .iter() + .chain(instance.triangle_entities.iter()) + .cloned() + .collect(); + + assert!(!all_entities.is_empty(), "sanity: some entities were spawned"); + + unload_template(&mut world, template_id); + + // All spawned entities must be gone. + for entity in &all_entities { + assert!( + world.get_entity(*entity).is_err(), + "entity {:?} must be despawned after unload_template", + entity, + ); + } + + // Instance removed from tracking. + let active = world.resource::<ActiveTemplateInstances>(); + assert!( + active.get(template_id).is_none(), + "unloaded template must be removed from ActiveTemplateInstances", + ); +} + +// --------------------------------------------------------------------------- +// Determinism: same seed → same NPC StableId assignment (D-010) +// --------------------------------------------------------------------------- + +#[test] +fn template_instantiation_is_deterministic() { + let path = templates_dir().join("logistics-hub.yaml"); + let template_def = load_template_from_file(&path).expect("must parse"); + let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); + + let mut world1 = make_test_world(); + let instance1 = + instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42)) + .expect("must instantiate"); + + let mut world2 = make_test_world(); + let instance2 = + instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42)) + .expect("must instantiate"); + + // Collect (role → StableId) pairs from each world and compare. + let role_stable_ids = |world: &World, entities: &[Entity]| { + let mut pairs: Vec<(String, u64)> = entities + .iter() + .map(|&e| { + let role = world.get::<TemplateOwnership>(e).unwrap().role_id.0.clone(); + let sid = world.get::<StableEntityId>(e).unwrap().0 .0; + (role, sid) + }) + .collect(); + pairs.sort(); + pairs + }; + + let pairs1 = role_stable_ids(&world1, &instance1.npc_entities); + let pairs2 = role_stable_ids(&world2, &instance2.npc_entities); + + assert_eq!( + pairs1, pairs2, + "instantiate_template must be deterministic (D-010): same seed → same layout" + ); +} + +// --------------------------------------------------------------------------- +// YAML loading: invalid path returns Err +// --------------------------------------------------------------------------- + +#[test] +fn load_template_from_file_nonexistent_path_returns_err() { + let path = templates_dir().join("nonexistent-template-xyzzy.yaml"); + let result = load_template_from_file(&path); + assert!( + result.is_err(), + "loading a nonexistent file must return Err" + ); +} diff --git a/server/tests/template_schema.rs b/server/tests/template_schema.rs new file mode 100644 index 000000000..c570eaa20 --- /dev/null +++ b/server/tests/template_schema.rs @@ -0,0 +1,906 @@ +//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159). +//! +//! Tests YAML round-trips, validation logic, and ECS component interactions +//! against the spec decisions: +//! - D-023: three-tier content model +//! - D-024: 10-axis NPC model, triangles as atomic unit +//! - D-025: social site / single-ownership model +//! - D-028: dialogue tagged pools +//! - D-087: v0.1 triangle configuration +//! - D-089: self-contained triangle forks, no cross-triangle cascade +//! - D-010: determinism (no HashMap, FNV-1a IDs) + +use settled_reach_server::content::template::{ + validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef, + NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec, + TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference, + TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId, + TrustRange, +}; +use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_role_schema(id: &str) -> RoleSchema { + RoleSchema { + role_id: RoleId::new(id), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![], + routine_template: vec![], + } +} + +fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef { + let role_arr = [ + RoleId::new(roles[0]), + RoleId::new(roles[1]), + RoleId::new(roles[2]), + ]; + let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr); + TriangleDef { + triangle_id, + roles: role_arr, + conflict_type: conflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + } +} + +// --------------------------------------------------------------------------- +// #163: Role definition schema — YAML round-trips +// --------------------------------------------------------------------------- + +#[test] +fn role_schema_minimal_yaml_parse() { + let yaml = r#" +role_id: "guard" +skill_focus: + - Combat + - Observation +"#; + let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse"); + assert_eq!(schema.role_id, RoleId::new("guard")); + assert_eq!(schema.skill_focus.len(), 2); + assert!(schema.required_traits.is_empty()); + assert!(schema.relationship_constraints.is_empty()); + assert!(schema.routine_template.is_empty()); +} + +#[test] +fn role_schema_full_yaml_parse() { + let yaml = r#" +role_id: "dock-worker" +required_traits: + - Cautious + - Honest +skill_focus: + - Technical + - Observation +relationship_constraints: + - with_role: "ring-contact" + kind: Colleague + required_trust: + min: -2 + max: 2 +routine_template: + - phase: "morning" + location: "terminal-cargo-bay" + activity: "freight-handling" + - phase: "evening" + location: "bar-last-shift" +"#; + let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse"); + assert_eq!(schema.role_id, RoleId::new("dock-worker")); + assert_eq!(schema.required_traits.len(), 2); + assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious); + assert_eq!(schema.skill_focus.len(), 2); + assert_eq!(schema.relationship_constraints.len(), 1); + assert_eq!( + schema.relationship_constraints[0].with_role, + RoleId::new("ring-contact") + ); + assert_eq!(schema.relationship_constraints[0].required_trust.min, -2); + assert_eq!(schema.relationship_constraints[0].required_trust.max, 2); + assert_eq!(schema.routine_template.len(), 2); + assert_eq!(schema.routine_template[0].phase, "morning"); + assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string())); + assert_eq!(schema.routine_template[1].activity, None); +} + +#[test] +fn role_schema_yaml_roundtrip_preserves_all_fields() { + let schema = RoleSchema { + role_id: RoleId::new("ring-contact"), + required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social], + skill_focus: vec![Skill::Stealth, Skill::Persuasion], + relationship_constraints: vec![ + RelationshipConstraint { + with_role: RoleId::new("dock-worker"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 4 }, + }, + RelationshipConstraint { + with_role: RoleId::new("ring-leader"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 1, max: 4 }, + }, + ], + routine_template: vec![ + TemplateRoutineEntry { + phase: "morning".into(), + location: "terminal-cargo-bay".into(), + activity: Some("oversight".into()), + }, + TemplateRoutineEntry { + phase: "evening".into(), + location: "maintenance-corridor".into(), + activity: None, + }, + ], + }; + + let yaml = serde_yaml::to_string(&schema).expect("serialize"); + let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize"); + + assert_eq!(restored.role_id, schema.role_id); + assert_eq!(restored.required_traits, schema.required_traits); + assert_eq!(restored.skill_focus, schema.skill_focus); + assert_eq!( + restored.relationship_constraints.len(), + schema.relationship_constraints.len() + ); + assert_eq!( + restored.relationship_constraints[0].required_trust, + schema.relationship_constraints[0].required_trust + ); + assert_eq!(restored.routine_template.len(), schema.routine_template.len()); + assert_eq!( + restored.routine_template[0].activity, + schema.routine_template[0].activity + ); +} + +// --------------------------------------------------------------------------- +// #163: Role definition schema — validation +// --------------------------------------------------------------------------- + +#[test] +fn self_referential_constraint_rejected() { + let schema = RoleSchema { + role_id: RoleId::new("dock-worker"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("dock-worker"), // same as role_id + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 4 }, + }], + routine_template: vec![], + }; + let result = schema.validate(); + assert!(result.is_err(), "self-referential constraint must be rejected"); + assert!( + result.unwrap_err().contains("self-referential"), + "error must mention self-referential" + ); +} + +#[test] +fn invalid_trust_range_rejected() { + let schema = RoleSchema { + role_id: RoleId::new("guard"), + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("captain"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max + }], + routine_template: vec![], + }; + let result = schema.validate(); + assert!(result.is_err(), "TrustRange min > max must be rejected"); + assert!( + result.unwrap_err().contains("trust min"), + "error must mention trust min" + ); +} + +#[test] +fn collection_with_duplicate_role_ids_rejected() { + let schemas = vec![ + make_role_schema("dock-worker"), + make_role_schema("ring-contact"), + make_role_schema("dock-worker"), // duplicate + ]; + let result = validate_role_schemas_no_duplicate_ids(&schemas); + assert!(result.is_err(), "duplicate role_ids must be rejected"); + let msg = result.unwrap_err(); + assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg); +} + +#[test] +fn collection_with_unique_role_ids_ok() { + let schemas = vec![ + make_role_schema("dock-worker"), + make_role_schema("ring-contact"), + make_role_schema("logistics-manager"), + ]; + assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok()); +} + +// --------------------------------------------------------------------------- +// #164: Spatial requirement specification — YAML round-trips +// --------------------------------------------------------------------------- + +#[test] +fn space_spec_minimal_yaml_parse() { + let yaml = r#" +tile_count_min: 30 +tile_count_max: 80 +privacy_level: Public +traffic_pattern: Thoroughfare +"#; + let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse"); + assert_eq!(spec.tile_count_min, 30); + assert_eq!(spec.tile_count_max, 80); + assert_eq!(spec.privacy_level, PrivacyLevel::Public); + assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare); + assert!(spec.sightline_zones.is_empty()); +} + +#[test] +fn space_spec_full_yaml_parse() { + let yaml = r#" +tile_count_min: 30 +tile_count_max: 80 +sightline_zones: + - name: "bar-counter" + radius: 4 + - name: "corner-booth" + radius: 2 +privacy_level: SemiPrivate +traffic_pattern: Destination +"#; + let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse"); + assert_eq!(spec.sightline_zones.len(), 2); + assert_eq!(spec.sightline_zones[0].name, "bar-counter"); + assert_eq!(spec.sightline_zones[0].radius, 4); + assert_eq!(spec.sightline_zones[1].name, "corner-booth"); + assert_eq!(spec.sightline_zones[1].radius, 2); + assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate); + assert_eq!(spec.traffic_pattern, TrafficPattern::Destination); +} + +/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066). +#[test] +fn space_spec_d025_tile_count_range() { + let spec = SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Public, + traffic_pattern: TrafficPattern::Destination, + }; + assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid"); +} + +#[test] +fn space_spec_validation_min_gt_max_fails() { + let spec = SpaceSpec { + tile_count_min: 100, + tile_count_max: 50, + sightline_zones: vec![], + privacy_level: PrivacyLevel::Private, + traffic_pattern: TrafficPattern::Restricted, + }; + let result = spec.validate(); + assert!(result.is_err(), "min > max must fail validation"); + let msg = result.unwrap_err(); + assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg); +} + +#[test] +fn all_privacy_levels_yaml_roundtrip() { + for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] { + let yaml = serde_yaml::to_string(level).unwrap(); + let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level); + } +} + +#[test] +fn all_traffic_patterns_yaml_roundtrip() { + for pattern in &[ + TrafficPattern::Thoroughfare, + TrafficPattern::Destination, + TrafficPattern::Restricted, + ] { + let yaml = serde_yaml::to_string(pattern).unwrap(); + let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern); + } +} + +// --------------------------------------------------------------------------- +// #165: Single-ownership model — TemplateId determinism +// --------------------------------------------------------------------------- + +#[test] +fn template_id_fnv1a_stable_across_calls() { + let id = TemplateId::from_seed_and_slug(0, ""); + assert_eq!( + id, + TemplateId::from_seed_and_slug(0, ""), + "empty slug + seed 0 must be stable" + ); + + let id2 = TemplateId::from_seed_and_slug(42, "the-terminal"); + assert_eq!( + id2, + TemplateId::from_seed_and_slug(42, "the-terminal"), + "non-empty slug must be stable" + ); +} + +#[test] +fn template_ownership_component_single_owner_invariant() { + // D-025: NPCs are owned by exactly one template, never reassigned. + let seed = 1u64; + let tid = TemplateId::from_seed_and_slug(seed, "terminal"); + let rid = RoleId::new("dock-worker"); + + let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() }; + assert_eq!(ownership.template_id, tid); + assert_eq!(ownership.role_id, rid); + + // Clone (as would happen in save-state) must preserve values. + let cloned = ownership.clone(); + assert_eq!(cloned.template_id, ownership.template_id); + assert_eq!(cloned.role_id, ownership.role_id); +} + +#[test] +fn template_reference_map_preserves_links_on_unload() { + // D-025: reference links must be preserved when a template is unloaded. + let mut map = TemplateReferenceMap::default(); + let tid_a = TemplateId::from_seed_and_slug(1, "template-a"); + let tid_b = TemplateId::from_seed_and_slug(1, "template-b"); + + map.add(TemplateReference { + from_template: tid_a, + to_template: tid_b, + via_role: RoleId::new("ring-contact"), + relationship_metadata: RelationshipKind::Colleague, + }); + + // Simulate "unload template-a" by cloning (the save path). + let preserved = map.clone(); + assert_eq!(preserved.outgoing(tid_a).len(), 1); + assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b); +} + +#[test] +fn template_reference_map_btreemap_deterministic_ordering() { + // D-010: BTreeMap ensures deterministic iteration order. + let mut map = TemplateReferenceMap::default(); + + let tid_high = TemplateId(u64::MAX - 1); + let tid_low = TemplateId(1); + + map.add(TemplateReference { + from_template: tid_high, + to_template: tid_low, + via_role: RoleId::new("role-a"), + relationship_metadata: RelationshipKind::Colleague, + }); + map.add(TemplateReference { + from_template: tid_low, + to_template: tid_high, + via_role: RoleId::new("role-b"), + relationship_metadata: RelationshipKind::Colleague, + }); + + // Collect all references via all_references() (deterministic BTreeMap order). + let all: Vec<&TemplateReference> = map.all_references().collect(); + assert_eq!(all.len(), 2); + // First entry's from_template must be the lower ID (BTreeMap key order). + assert!( + all[0].from_template <= all[1].from_template, + "BTreeMap must iterate in ascending key order" + ); +} + +// --------------------------------------------------------------------------- +// #106: Triangle definition schema — YAML round-trips +// --------------------------------------------------------------------------- + +#[test] +fn triangle_def_yaml_parse_with_computed_id() { + // TriangleId is stored in YAML but computed at world-gen time. + // Authors use 0 as placeholder; runtime overwrites with computed value. + let yaml = r#" +triangle_id: 0 +roles: + - "ring-smuggler" + - "dock-worker" + - "operations-manager" +conflict_type: ResourceCompetition +interest_axes: + - Want + - Secret + - Relationships +"#; + let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML"); + assert_eq!(def.triangle_id, TriangleId(0)); + assert_eq!(def.roles[0], RoleId::new("ring-smuggler")); + assert_eq!(def.conflict_type, ConflictType::ResourceCompetition); + assert!(def.relationship_constraints.is_empty()); +} + +#[test] +fn triangle_def_yaml_parse_with_constraints() { + let yaml = r#" +triangle_id: 0 +roles: + - "ring-leader" + - "dock-worker" + - "logistics-manager" +conflict_type: LoyaltyConflict +interest_axes: + - Relationships + - Secret + - Tolerance +relationship_constraints: + - with_role: "dock-worker" + kind: Subordinate + required_trust: + min: -2 + max: 2 +"#; + let def: TriangleDef = + serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse"); + assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict); + assert_eq!(def.relationship_constraints.len(), 1); + assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker")); + assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate); +} + +#[test] +fn triangle_def_all_conflict_types_yaml_roundtrip() { + let conflict_types = [ + ConflictType::ResourceCompetition, + ConflictType::LoyaltyConflict, + ConflictType::SecretExposure, + ConflictType::AuthorityChallenge, + ConflictType::LatentTension, + ]; + for ct in &conflict_types { + let yaml = serde_yaml::to_string(ct).unwrap(); + let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(ct, &decoded, "{:?} must round-trip", ct); + } +} + +#[test] +fn triangle_def_all_npc_axes_yaml_roundtrip() { + let axes = [ + NpcAxis::Want, + NpcAxis::Secret, + NpcAxis::Relationships, + NpcAxis::Tolerance, + NpcAxis::Routine, + NpcAxis::InformationInventory, + NpcAxis::Contentment, + NpcAxis::PersonalityTraits, + NpcAxis::TellSystem, + NpcAxis::SkillSet, + ]; + for axis in &axes { + let yaml = serde_yaml::to_string(axis).unwrap(); + let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(axis, &decoded, "{:?} must round-trip", axis); + } +} + +/// D-087: T1-T5 triangle configuration must be expressible in the schema. +#[test] +fn d087_v01_triangle_configurations_expressible() { + // T1: Kael-Smuggler-Ring (ResourceCompetition, active fork) + let t1 = make_triangle( + ["kael-davan", "smuggler", "ring-contact"], + ConflictType::ResourceCompetition, + ); + assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate()); + + // T2: Sera-Detective-Commission (SecretExposure, active fork) + let t2 = make_triangle( + ["sera-venn", "detective", "commission-inspector"], + ConflictType::SecretExposure, + ); + assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate()); + + // T4: Drin-System-Ring (ResourceCompetition, active fork per D-087) + let t4 = make_triangle( + ["drin", "ring-system", "dock-supervisor"], + ConflictType::ResourceCompetition, + ); + assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate()); + + // T3: passive tension (LatentTension variant per D-087) + let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension); + assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate()); + + // T5: background worried partner (LatentTension variant) + let t5 = make_triangle( + ["worried-partner", "ring-member", "neighbor"], + ConflictType::LatentTension, + ); + assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate()); +} + +/// D-089: TriangleDef must not contain cross-triangle cascade state. +#[test] +fn d089_no_cross_triangle_cascade_fields() { + let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition); + let yaml = serde_yaml::to_string(&def).expect("serialize"); + assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef"); + assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear"); + assert!(!yaml.contains("triggers"), "no triggers field should appear"); +} + +// --------------------------------------------------------------------------- +// #165: ECS integration — spawn two templates with cross-references +// --------------------------------------------------------------------------- + +#[test] +fn ecs_two_templates_with_cross_references_and_ownerships() { + use bevy_ecs::world::World; + + let seed = 999u64; + let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site"); + let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar"); + + let mut world = World::new(); + world.init_resource::<TemplateReferenceMap>(); + + // Spawn 3 NPCs: 2 in terminal, 1 in bar. + let npc_logistics = world + .spawn(TemplateOwnership { + template_id: tid_terminal, + role_id: RoleId::new("logistics-manager"), + }) + .id(); + let npc_dock = world + .spawn(TemplateOwnership { + template_id: tid_terminal, + role_id: RoleId::new("dock-worker"), + }) + .id(); + let npc_bar_regular = world + .spawn(TemplateOwnership { + template_id: tid_bar, + role_id: RoleId::new("bar-regular"), + }) + .id(); + + // Add cross-template reference: dock-worker at terminal references bar-regular at bar. + { + let mut ref_map = world.resource_mut::<TemplateReferenceMap>(); + ref_map.add(TemplateReference { + from_template: tid_terminal, + to_template: tid_bar, + via_role: RoleId::new("dock-worker"), + relationship_metadata: RelationshipKind::Colleague, + }); + } + + // Verify all TemplateOwnership components are correct. + let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap(); + assert_eq!( + own_logistics.template_id, tid_terminal, + "logistics-manager must be owned by terminal" + ); + assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager")); + + let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap(); + assert_eq!( + own_dock.template_id, tid_terminal, + "dock-worker must be owned by terminal" + ); + assert_eq!(own_dock.role_id, RoleId::new("dock-worker")); + + let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap(); + assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar"); + + // Verify TemplateReferenceMap entries. + let ref_map = world.resource::<TemplateReferenceMap>(); + let terminal_refs = ref_map.outgoing(tid_terminal); + assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference"); + assert_eq!(terminal_refs[0].to_template, tid_bar); + assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker")); + + // Bar template has no outgoing references. + assert!( + ref_map.outgoing(tid_bar).is_empty(), + "bar template has no outgoing references" + ); +} + +#[test] +fn template_ownership_survives_clone_for_save_state() { + // D-026: TemplateOwnership must be preserved when tier drops to State-saved. + let tid = TemplateId::from_seed_and_slug(42, "terminal"); + let rid = RoleId::new("dock-worker"); + let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() }; + let saved = ownership.clone(); + assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)"); +} + +// --------------------------------------------------------------------------- +// #159: Full Tier 2 template document — FullTemplateDef +// --------------------------------------------------------------------------- + +/// Build a minimal valid FullTemplateDef with two roles and two triangles. +fn minimal_full_template() -> FullTemplateDef { + FullTemplateDef { + slug: "test-site".to_string(), + display_name: "Test Social Site".to_string(), + description: None, + roles: vec![ + RoleSchema { + role_id: RoleId::new("manager"), + required_traits: vec![PersonalityTrait::Cautious], + skill_focus: vec![Skill::Persuasion], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("worker"), + kind: RelationshipKind::Superior, + required_trust: TrustRange { min: 0, max: 5 }, + }], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("worker"), + required_traits: vec![PersonalityTrait::Honest], + skill_focus: vec![Skill::Technical], + relationship_constraints: vec![], + routine_template: vec![], + }, + RoleSchema { + role_id: RoleId::new("informant"), + required_traits: vec![PersonalityTrait::Deceptive], + skill_focus: vec![Skill::Stealth], + relationship_constraints: vec![], + routine_template: vec![], + }, + ], + space: SpaceSpec { + tile_count_min: 30, + tile_count_max: 80, + sightline_zones: vec![SightlineZone { + name: "main-floor".to_string(), + radius: 6, + }], + privacy_level: PrivacyLevel::SemiPrivate, + traffic_pattern: TrafficPattern::Destination, + }, + triangles: vec![ + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("worker"), + RoleId::new("informant"), + ], + conflict_type: ConflictType::ResourceCompetition, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }, + TriangleDef { + triangle_id: TriangleId(0), + roles: [ + RoleId::new("manager"), + RoleId::new("informant"), + RoleId::new("worker"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine], + relationship_constraints: vec![], + }, + ], + dialogue_pools: vec![TemplateDialoguePoolRef { + location: "the-hub".to_string(), + roles: vec!["manager".to_string(), "worker".to_string()], + }], + cross_template_links: vec![CrossTemplateLinkSpec { + from_role: RoleId::new("worker"), + to_template_slug: "other-site".to_string(), + relationship: RelationshipKind::Colleague, + }], + } +} + +#[test] +fn full_template_def_yaml_roundtrip() { + let template = minimal_full_template(); + let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef"); + let restored: FullTemplateDef = + serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef"); + + assert_eq!(restored.slug, template.slug); + assert_eq!(restored.display_name, template.display_name); + assert_eq!(restored.roles.len(), template.roles.len()); + assert_eq!(restored.space.tile_count_min, template.space.tile_count_min); + assert_eq!(restored.triangles.len(), template.triangles.len()); + assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len()); + assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len()); + + // Role round-trip: traits, constraints, routine entries + let role = &restored.roles[0]; + assert_eq!(role.role_id, RoleId::new("manager")); + assert_eq!(role.required_traits[0], PersonalityTrait::Cautious); + assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker")); + + // Triangle round-trip: roles, conflict type, axes + let tri = &restored.triangles[0]; + assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition); + assert_eq!(tri.roles[0], RoleId::new("manager")); + assert_eq!(tri.interest_axes[1], NpcAxis::Secret); + + // Dialogue pool round-trip + assert_eq!(restored.dialogue_pools[0].location, "the-hub"); + assert_eq!(restored.dialogue_pools[0].roles.len(), 2); + + // Cross-template link round-trip + assert_eq!( + restored.cross_template_links[0].from_role, + RoleId::new("worker") + ); + assert_eq!( + restored.cross_template_links[0].to_template_slug, + "other-site" + ); +} + +#[test] +fn full_template_def_validation_passes_for_valid_template() { + let template = minimal_full_template(); + assert!( + template.validate().is_ok(), + "minimal valid template must pass: {:?}", + template.validate() + ); +} + +#[test] +fn full_template_def_validation_rejects_fewer_than_2_triangles() { + let mut template = minimal_full_template(); + template.triangles.truncate(1); + let result = template.validate(); + assert!(result.is_err(), "fewer than 2 triangles must fail"); + assert!( + result.unwrap_err().contains("fewer than 2 triangles"), + "error must mention triangle count" + ); +} + +#[test] +fn full_template_def_validation_rejects_undefined_triangle_role() { + let mut template = minimal_full_template(); + // Replace a triangle role with one not in the roles list + template.triangles[0].roles[2] = RoleId::new("ghost-role"); + let result = template.validate(); + assert!(result.is_err(), "undefined triangle role must fail validation"); + assert!( + result.unwrap_err().contains("ghost-role"), + "error must name the undefined role" + ); +} + +#[test] +fn full_template_def_validation_rejects_duplicate_role_ids() { + let mut template = minimal_full_template(); + template.roles.push(RoleSchema { + role_id: RoleId::new("manager"), // duplicate + required_traits: vec![], + skill_focus: vec![], + relationship_constraints: vec![], + routine_template: vec![], + }); + let result = template.validate(); + assert!(result.is_err(), "duplicate role_id must fail validation"); +} + +#[test] +fn full_template_def_optional_fields_default_on_minimal_yaml() { + // description, dialogue_pools, cross_template_links are all optional. + let yaml = r#" +slug: "bare-minimum" +display_name: "Bare Minimum Site" +roles: + - role_id: "alpha" + - role_id: "beta" + - role_id: "gamma" +space: + tile_count_min: 30 + tile_count_max: 80 + privacy_level: Public + traffic_pattern: Thoroughfare +triangles: + - triangle_id: 0 + roles: + - "alpha" + - "beta" + - "gamma" + conflict_type: LatentTension + interest_axes: + - Contentment + - Tolerance + - Routine + - triangle_id: 0 + roles: + - "alpha" + - "gamma" + - "beta" + conflict_type: ResourceCompetition + interest_axes: + - Want + - Secret + - Relationships +"#; + let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse"); + assert_eq!(def.slug, "bare-minimum"); + assert!(def.description.is_none()); + assert!(def.dialogue_pools.is_empty()); + assert!(def.cross_template_links.is_empty()); + assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate()); +} + +/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml. +/// +/// The file lives at `server/data/templates/logistics-hub.yaml`. +/// This test is the canonical acceptance criterion for ticket #159. +#[test] +fn logistics_hub_yaml_roundtrips_cleanly() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/data/templates/logistics-hub.yaml" + ); + let raw = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e)); + + let def: FullTemplateDef = serde_yaml::from_str(&raw) + .unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e)); + + // Structural assertions + assert_eq!(def.slug, "logistics-hub"); + assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles"); + assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles"); + assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present"); + assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present"); + + // Spatial spec assertions (D-025: 30–80 sim tiles) + assert!(def.space.validate().is_ok(), "space spec must validate"); + assert_eq!(def.space.tile_count_min, 30); + assert_eq!(def.space.tile_count_max, 80); + + // Validation must pass + assert!( + def.validate().is_ok(), + "logistics-hub.yaml must pass full validation: {:?}", + def.validate() + ); + + // Round-trip: serialize back to YAML then deserialize again + let reserialized = serde_yaml::to_string(&def).expect("re-serialize"); + let restored: FullTemplateDef = + serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip"); + assert_eq!(def.slug, restored.slug); + assert_eq!(def.roles.len(), restored.roles.len()); + assert_eq!(def.triangles.len(), restored.triangles.len()); + assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len()); + assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len()); +} diff --git a/server/tests/triangle_escalation.rs b/server/tests/triangle_escalation.rs new file mode 100644 index 000000000..78007c317 --- /dev/null +++ b/server/tests/triangle_escalation.rs @@ -0,0 +1,613 @@ +//! Integration tests for the triangle escalation system (#250). +//! +//! Covers the public API from a black-box perspective: +//! - D-087: seed-dependent tension rates produce different 30-min arc timings +//! - D-089: resolution does not cascade (only targeted triangle changes) +//! - D-026: escalation only runs on Active-tier entities +//! - D-031: escalation runs once per game-minute (every 10 ticks) +//! +//! These tests complement the lib unit tests in `src/content/template.rs` +//! with integration-level coverage using the public crate API. + +use std::collections::BTreeMap; + +use bevy_ecs::{schedule::Schedule, world::World}; +use settled_reach_server::{ + content::template::{ + apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand, + ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId, + TrianglePhase, TriangleState, + }, + knowledge::{registry::EntityRegistry, types::StableId, StableEntityId}, + npc::ToleranceThreshold, + simulation::{ + tier::ActiveSim, + time::SimulationTime, + }, +}; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/// Minimal world with all resources required by `tick_triangle_escalation`. +fn make_escalation_world() -> World { + let mut world = World::new(); + world.init_resource::<SimulationTime>(); + world.init_resource::<TriangleCrisisEventQueue>(); + world.init_resource::<EntityRegistry>(); + world +} + +/// Spawn an NPC with a known StableId and ToleranceThreshold. +fn spawn_npc_with_threshold(world: &mut World, stable_id_val: u64, threshold: i16) -> StableId { + let sid = StableId(stable_id_val); + let entity = world + .spawn((ActiveSim, StableEntityId(sid), ToleranceThreshold { current_stress: 0, threshold })) + .id(); + world.resource_mut::<EntityRegistry>().register_existing(entity, sid); + sid +} + +/// Spawn a triangle entity with the given state (ActiveSim marker included). +fn spawn_triangle( + world: &mut World, + triangle_id: u64, + tension: u8, + tension_rate: u8, + phase: TrianglePhase, + role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>, +) -> bevy_ecs::entity::Entity { + world + .spawn(( + ActiveSim, + TriangleState { + triangle_id: TriangleId(triangle_id), + role_assignments, + tension, + phase, + tension_rate, + template_id: TemplateId(1), + }, + )) + .id() +} + +/// Run the escalation schedule at a specific tick. +fn run_at_tick(world: &mut World, schedule: &mut Schedule, tick: u64) { + world.resource_mut::<SimulationTime>().tick = tick; + schedule.run(world); +} + +// --------------------------------------------------------------------------- +// #250: Escalation happy path +// --------------------------------------------------------------------------- + +/// D-031: escalation runs once per game-minute. 10 ticks = 1 game-minute. +/// Tension should only increment on multiples of 10. +#[test] +fn escalation_only_fires_on_game_minute_boundaries() { + let mut world = make_escalation_world(); + let entity = spawn_triangle( + &mut world, + 1, + 0, + 5, + TrianglePhase::Simmering, + BTreeMap::new(), + ); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Ticks 1-9: not a game-minute, tension must not change. + for tick in 1..10 { + run_at_tick(&mut world, &mut schedule, tick); + } + assert_eq!( + world.get::<TriangleState>(entity).unwrap().tension, + 0, + "tension must not change on sub-minute ticks" + ); + + // Tick 10: first game-minute, tension should increment. + run_at_tick(&mut world, &mut schedule, 10); + assert_eq!( + world.get::<TriangleState>(entity).unwrap().tension, + 5, + "tension must increment at tick 10 (first game-minute)" + ); +} + +/// Simmering → Active transition at the expected game-minute. +/// +/// Known setup: +/// - tension_rate = 5, starting tension = 0 +/// - Lowest NPC threshold = 25 +/// - After 5 game-minutes (50 ticks): tension = 25, not > 25 → Simmering +/// - After 6 game-minutes (60 ticks): tension = 30, 30 > 25 → Active +#[test] +fn simmering_transitions_to_active_at_expected_minute() { + let mut world = make_escalation_world(); + + let npc_a = spawn_npc_with_threshold(&mut world, 1, 40); + let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest + let npc_c = spawn_npc_with_threshold(&mut world, 3, 60); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("role-a"), npc_a); + assignments.insert(RoleId::new("role-b"), npc_b); + assignments.insert(RoleId::new("role-c"), npc_c); + + let entity = spawn_triangle(&mut world, 42, 0, 5, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Run through 50 ticks (5 game-minutes): should remain Simmering. + for tick in 1..=50 { + run_at_tick(&mut world, &mut schedule, tick); + } + let state = world.get::<TriangleState>(entity).unwrap(); + assert_eq!( + state.phase, + TrianglePhase::Simmering, + "after 5 game-minutes (tension=25), must still be Simmering (not > 25)" + ); + assert_eq!(state.tension, 25); + + // Run through tick 60 (6th game-minute): tension becomes 30, > 25 → Active. + for tick in 51..=60 { + run_at_tick(&mut world, &mut schedule, tick); + } + let state = world.get::<TriangleState>(entity).unwrap(); + assert_eq!( + state.phase, + TrianglePhase::Active, + "at tick 60 (tension=30 > threshold=25), must transition to Active" + ); + assert_eq!(state.tension, 30); +} + +/// D-087: different seeds produce different escalation timings. +/// Verify that two triangles with different tension rates escalate at different times. +#[test] +fn d087_seed_dependent_escalation_timing() { + // Triangle A: slower escalation (rate 2) + // Triangle B: faster escalation (rate 8) + // Both share same NPC threshold (30). + // A triggers at: ceil(30 / 2) + 1 = 16th game-minute (tension hits 32 at minute 16) + // B triggers at: ceil(30 / 8) + 1 = 5th game-minute (tension hits 32 at minute 4) + + let mut world = make_escalation_world(); + + let npc = spawn_npc_with_threshold(&mut world, 1, 30); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + // Spawn as separate triangles. + let slow = spawn_triangle(&mut world, 10, 0, 2, TrianglePhase::Simmering, assignments.clone()); + let fast = spawn_triangle(&mut world, 20, 0, 8, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + + // Run 40 game-minutes (400 ticks). + for tick in 1..=400 { + run_at_tick(&mut world, &mut schedule, tick); + } + + // Both should be Active by 400 ticks. + assert_eq!(world.get::<TriangleState>(slow).unwrap().phase, TrianglePhase::Active); + assert_eq!(world.get::<TriangleState>(fast).unwrap().phase, TrianglePhase::Active); + + // Fast triangle should have activated earlier (higher tension accumulated faster). + let fast_tension = world.get::<TriangleState>(fast).unwrap().tension; + let slow_tension = world.get::<TriangleState>(slow).unwrap().tension; + assert!( + fast_tension > slow_tension, + "fast triangle (rate=8) should have higher tension than slow (rate=2) after equal time" + ); +} + +/// The trigger NPC in the crisis event is the one with the lowest threshold. +#[test] +fn crisis_event_trigger_npc_is_lowest_threshold() { + let mut world = make_escalation_world(); + + let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance + let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r-high"), npc_high); + assignments.insert(RoleId::new("r-low"), npc_low); + + // tension_rate = 11 so after 1 game-minute tension = 11 > 10. + spawn_triangle(&mut world, 99, 0, 11, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + let queue = world.resource::<TriangleCrisisEventQueue>(); + assert_eq!(queue.events.len(), 1, "exactly one crisis event"); + assert_eq!( + queue.events[0].trigger_npc, npc_low, + "trigger NPC must be the one with the lowest threshold" + ); + assert_eq!(queue.events[0].tick, 10, "crisis tick must match the game-minute"); +} + +/// No crisis event when tension hasn't exceeded the threshold. +#[test] +fn no_crisis_event_below_threshold() { + let mut world = make_escalation_world(); + let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + spawn_triangle(&mut world, 1, 0, 5, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + let queue = world.resource::<TriangleCrisisEventQueue>(); + assert!(queue.is_empty(), "no crisis event when tension (5) < threshold (100)"); +} + +// --------------------------------------------------------------------------- +// #250: Active phase behavior +// --------------------------------------------------------------------------- + +/// Active triangle continues incrementing tension (narrative tracking). +/// No additional crisis event emitted. +#[test] +fn active_triangle_continues_incrementing_no_new_event() { + let mut world = make_escalation_world(); + + spawn_triangle(&mut world, 1, 50, 3, TrianglePhase::Active, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + run_at_tick(&mut world, &mut schedule, 20); + + let queue = world.resource::<TriangleCrisisEventQueue>(); + assert!(queue.is_empty(), "no crisis event for already-Active triangle"); +} + +/// Active triangle tension saturates at u8::MAX (255). +#[test] +fn active_triangle_tension_saturates_at_u8_max() { + let mut world = make_escalation_world(); + spawn_triangle(&mut world, 1, 252, 10, TrianglePhase::Active, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + // First call: 252 + 10 = 262, saturates to 255 + let entity = world.query::<bevy_ecs::entity::Entity>().iter(&world).next().unwrap(); + // Can't query TriangleState after mutable borrow; check via resource + // (we verify by spawning directly and checking post-run) + let _ = entity; // entity used to ensure spawn worked + + // Re-run test cleanly + let mut world2 = make_escalation_world(); + let e2 = spawn_triangle(&mut world2, 2, 254, 50, TrianglePhase::Active, BTreeMap::new()); + let mut sched2 = Schedule::default(); + sched2.add_systems(tick_triangle_escalation); + run_at_tick(&mut world2, &mut sched2, 10); + + let state = world2.get::<TriangleState>(e2).unwrap(); + assert_eq!(state.tension, 255, "tension saturates at u8::MAX"); +} + +// --------------------------------------------------------------------------- +// #250: D-026 tier boundary +// --------------------------------------------------------------------------- + +/// Triangles without ActiveSim marker are NOT escalated (D-026 tier boundary). +#[test] +fn d026_non_active_tier_triangle_not_escalated() { + let mut world = make_escalation_world(); + world.resource_mut::<SimulationTime>().tick = 10; + + // Spawn WITHOUT ActiveSim. + let entity = world + .spawn(TriangleState { + triangle_id: TriangleId(1), + role_assignments: BTreeMap::new(), + tension: 10, + phase: TrianglePhase::Simmering, + tension_rate: 5, + template_id: TemplateId(1), + }) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + schedule.run(&mut world); + + assert_eq!( + world.get::<TriangleState>(entity).unwrap().tension, + 10, + "D-026: triangle without ActiveSim must not be escalated" + ); +} + +/// Dormant triangles are skipped even when in Active tier. +#[test] +fn dormant_triangle_not_escalated() { + let mut world = make_escalation_world(); + let entity = spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Dormant, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + assert_eq!( + world.get::<TriangleState>(entity).unwrap().tension, + 0, + "Dormant triangle must not be escalated" + ); +} + +/// Resolved triangles are skipped (D-089: resolution is permanent). +#[test] +fn resolved_triangle_not_escalated() { + let mut world = make_escalation_world(); + let entity = spawn_triangle(&mut world, 1, 50, 5, TrianglePhase::Resolved, BTreeMap::new()); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + assert_eq!( + world.get::<TriangleState>(entity).unwrap().tension, + 50, + "Resolved triangle must not be escalated (D-089)" + ); +} + +// --------------------------------------------------------------------------- +// #250: Resolution (D-089) +// --------------------------------------------------------------------------- + +/// ResolveTriangleCommand sets the target triangle to Resolved. +#[test] +fn resolve_command_sets_phase_to_resolved() { + let mut world = World::new(); + world.init_resource::<ResolveTriangleQueue>(); + + let entity = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100))); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + let state = world.get::<TriangleState>(entity).unwrap(); + assert_eq!(state.phase, TrianglePhase::Resolved, "resolve command must set phase to Resolved"); + assert_eq!(state.tension, 50, "tension must not change on resolve"); +} + +/// D-089: Resolution does NOT cascade to other triangles. +#[test] +fn d089_resolve_does_not_cascade() { + let mut world = World::new(); + world.init_resource::<ResolveTriangleQueue>(); + + let target = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 50, + phase: TrianglePhase::Active, + tension_rate: 3, + template_id: TemplateId(1), + }) + .id(); + + let bystander_a = world + .spawn(TriangleState { + triangle_id: TriangleId(200), + role_assignments: BTreeMap::new(), + tension: 20, + phase: TrianglePhase::Simmering, + tension_rate: 2, + template_id: TemplateId(1), + }) + .id(); + + let bystander_b = world + .spawn(TriangleState { + triangle_id: TriangleId(300), + role_assignments: BTreeMap::new(), + tension: 80, + phase: TrianglePhase::Active, + tension_rate: 4, + template_id: TemplateId(1), + }) + .id(); + + // Resolve only triangle 100. + world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100))); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + schedule.run(&mut world); + + assert_eq!( + world.get::<TriangleState>(target).unwrap().phase, + TrianglePhase::Resolved + ); + assert_eq!( + world.get::<TriangleState>(bystander_a).unwrap().phase, + TrianglePhase::Simmering, + "D-089: bystander_a must remain Simmering" + ); + assert_eq!( + world.get::<TriangleState>(bystander_b).unwrap().phase, + TrianglePhase::Active, + "D-089: bystander_b must remain Active" + ); +} + +/// Resolving the same triangle twice is idempotent. +#[test] +fn resolve_twice_is_idempotent() { + let mut world = World::new(); + world.init_resource::<ResolveTriangleQueue>(); + + let entity = world + .spawn(TriangleState { + triangle_id: TriangleId(100), + role_assignments: BTreeMap::new(), + tension: 30, + phase: TrianglePhase::Active, + tension_rate: 1, + template_id: TemplateId(1), + }) + .id(); + + let mut schedule = Schedule::default(); + schedule.add_systems(apply_resolve_triangle); + + world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100))); + schedule.run(&mut world); + world.resource_mut::<ResolveTriangleQueue>().push(ResolveTriangleCommand(TriangleId(100))); + schedule.run(&mut world); + + assert_eq!( + world.get::<TriangleState>(entity).unwrap().phase, + TrianglePhase::Resolved, + "double-resolve must remain Resolved" + ); +} + +// --------------------------------------------------------------------------- +// #250: Crisis event queue behavior +// --------------------------------------------------------------------------- + +/// Crisis events accumulate in the queue until drained. +#[test] +fn crisis_events_accumulate_until_drained() { + let mut world = make_escalation_world(); + + let npc = spawn_npc_with_threshold(&mut world, 1, 5); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + // Two triangles that will both escalate. + spawn_triangle(&mut world, 10, 0, 6, TrianglePhase::Simmering, assignments.clone()); + spawn_triangle(&mut world, 20, 0, 6, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + let queue = world.resource::<TriangleCrisisEventQueue>(); + assert_eq!( + queue.events.len(), + 2, + "both triangles should emit crisis events in the same game-minute" + ); +} + +/// `TriangleCrisisEventQueue::drain` clears the queue. +#[test] +fn crisis_queue_drain_clears_events() { + let mut world = make_escalation_world(); + + let npc = spawn_npc_with_threshold(&mut world, 1, 5); + + use settled_reach_server::content::template::RoleId; + let mut assignments = BTreeMap::new(); + assignments.insert(RoleId::new("r"), npc); + + spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Simmering, assignments); + + let mut schedule = Schedule::default(); + schedule.add_systems(tick_triangle_escalation); + run_at_tick(&mut world, &mut schedule, 10); + + // Drain the queue. + let drained = world.resource_mut::<TriangleCrisisEventQueue>().drain(); + assert_eq!(drained.len(), 1, "drain should return the 1 event"); + assert!( + world.resource::<TriangleCrisisEventQueue>().is_empty(), + "queue must be empty after drain" + ); +} + +// --------------------------------------------------------------------------- +// #250: YAML triangle def → escalation pipeline +// --------------------------------------------------------------------------- + +/// End-to-end: TriangleDef from YAML can describe all 5 v0.1 triangles +/// (D-087) and those defs produce escalatable TriangleState instances. +#[test] +fn d087_all_v01_conflict_types_produce_escalatable_states() { + use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId}; + + let defs = [ + ("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition), + ("sera-venn", "detective", "commission-inspector", ConflictType::SecretExposure), + ("naia", "kael-davan", "hael", ConflictType::LatentTension), + ("drin", "ring-system", "dock-supervisor", ConflictType::ResourceCompetition), + ("worried-partner", "ring-member", "neighbor", ConflictType::LatentTension), + ]; + + for (r0, r1, r2, conflict) in &defs { + let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)]; + let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles); + let def = TriangleDef { + triangle_id: tid, + roles: roles.clone(), + conflict_type: *conflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }; + assert!(def.validate().is_ok(), "D-087 triangle must be valid: {:?}", def.validate()); + + // Can construct a TriangleState from the def. + let mut assignments = BTreeMap::new(); + for role in &roles { + assignments.insert(role.clone(), StableId(0)); + } + let state = TriangleState { + triangle_id: tid, + role_assignments: assignments, + tension: 0, + phase: TrianglePhase::Simmering, + tension_rate: 3, + template_id: TemplateId(1), + }; + assert_eq!( + state.phase, + TrianglePhase::Simmering, + "{:?} triangle must start Simmering", + conflict + ); + } +} diff --git a/server/tests/triangle_validation.rs b/server/tests/triangle_validation.rs new file mode 100644 index 000000000..0a5355e97 --- /dev/null +++ b/server/tests/triangle_validation.rs @@ -0,0 +1,442 @@ +//! Integration tests for triangle validation and cross-template generation (#108, #109). +//! +//! Spec references: +//! - D-024: NPC generation model — minimum 2 triangles per template, 1 cross-template +//! - D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions +//! - D-025: social site as atomic template unit — cross-template reference links +//! +//! Test naming follows the cargo test filter target: +//! `cargo test -p settled-reach-server -- triangle_validation` + +use settled_reach_server::{ + content::template::{ + generate_cross_template_triangles, generate_intra_template_triangles, + validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId, + TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange, + ValidationError, + }, + knowledge::{registry::StableEntityId, types::StableId}, + npc::{Npc, RelationshipKind}, + simulation::rng::SimRng, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Minimal valid TriangleDef that passes all three validation checks. +/// +/// - interest_axes: [Want, Secret, Relationships] → all distinct, has Want +/// - relationship_constraints: non-empty +fn valid_triangle_def(id: u64) -> TriangleDef { + TriangleDef { + triangle_id: TriangleId(id), + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("inspector"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("freight-handler"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 1, max: 5 }, + }], + } +} + +/// Spawn an NPC entity with TemplateOwnership in the given world. +fn spawn_template_npc( + world: &mut bevy_ecs::world::World, + template_id: TemplateId, + role: &str, + stable_id: u64, +) { + world.spawn(( + Npc, + TemplateOwnership { + template_id, + role_id: RoleId::new(role), + }, + StableEntityId(StableId(stable_id)), + )); +} + +// --------------------------------------------------------------------------- +// #109 — Triangle validation: unit tests for each failure mode +// --------------------------------------------------------------------------- + +/// A valid triangle passes all three validation checks. +#[test] +fn triangle_validation_valid_triangle_passes_all_checks() { + let def = valid_triangle_def(1); + assert!( + validate_triangle_def(&def).is_ok(), + "valid triangle must pass all checks: {:?}", + validate_triangle_def(&def) + ); +} + +/// Conflict viability fails when no interest_axes entry is NpcAxis::Want. +/// +/// D-024: active conflict requires at least one role whose tension is Want-driven. +#[test] +fn triangle_validation_conflict_viability_fails_without_want_axis() { + let def = TriangleDef { + triangle_id: TriangleId(10), + roles: [ + RoleId::new("worker-a"), + RoleId::new("worker-b"), + RoleId::new("supervisor"), + ], + conflict_type: ConflictType::LatentTension, + // No Want axis — all passive tensions + interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("worker-b"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 3 }, + }], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!(result, Err(ValidationError::ConflictViability { triangle_id: TriangleId(10) })), + "expected ConflictViability error, got: {:?}", + result + ); +} + +/// Relationship coherence fails when relationship_constraints is empty. +/// +/// A coherent triangle must document at least one social link among the three roles. +#[test] +fn triangle_validation_relationship_coherence_fails_without_constraints() { + let def = TriangleDef { + triangle_id: TriangleId(20), + roles: [ + RoleId::new("smuggler"), + RoleId::new("detective"), + RoleId::new("informant"), + ], + conflict_type: ConflictType::SecretExposure, + // Has Want axis (passes conflict viability) + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + // Empty constraints — fails coherence + relationship_constraints: vec![], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!(result, Err(ValidationError::RelationshipCoherence { triangle_id: TriangleId(20) })), + "expected RelationshipCoherence error, got: {:?}", + result + ); +} + +/// Interest divergence fails when two roles share the same interest_axis. +/// +/// All three axes must be distinct so each role brings a different tension. +#[test] +fn triangle_validation_interest_divergence_fails_with_duplicate_axes() { + let def = TriangleDef { + triangle_id: TriangleId(30), + roles: [ + RoleId::new("dock-worker"), + RoleId::new("cargo-lead"), + RoleId::new("port-officer"), + ], + conflict_type: ConflictType::ResourceCompetition, + // Two roles both have Want — duplicate axis + interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Secret], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("cargo-lead"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!( + result, + Err(ValidationError::InterestDivergence { + triangle_id: TriangleId(30), + duplicate_axis: NpcAxis::Want, + }) + ), + "expected InterestDivergence(Want) error, got: {:?}", + result + ); +} + +/// Divergence check catches the third axis duplicating the first. +/// +/// Edge case: axes[0] == axes[2], but axes[1] is different. +#[test] +fn triangle_validation_interest_divergence_first_last_duplicate() { + let def = TriangleDef { + triangle_id: TriangleId(31), + roles: [ + RoleId::new("role-a"), + RoleId::new("role-b"), + RoleId::new("role-c"), + ], + conflict_type: ConflictType::AuthorityChallenge, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Want], // 0 == 2 + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("role-b"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 5 }, + }], + }; + + let result = validate_triangle_def(&def); + assert!( + matches!( + result, + Err(ValidationError::InterestDivergence { + triangle_id: TriangleId(31), + duplicate_axis: NpcAxis::Want, + }) + ), + "expected InterestDivergence(Want) for axes[0]==axes[2], got: {:?}", + result + ); +} + +/// Validation checks are ordered: ConflictViability fires before Coherence. +/// +/// A def with no Want axis AND empty constraints should fail with +/// ConflictViability, not RelationshipCoherence. +#[test] +fn triangle_validation_conflict_viability_checked_before_coherence() { + let def = TriangleDef { + triangle_id: TriangleId(40), + roles: [ + RoleId::new("a"), + RoleId::new("b"), + RoleId::new("c"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], + relationship_constraints: vec![], // also fails coherence + }; + + let result = validate_triangle_def(&def); + assert!( + matches!(result, Err(ValidationError::ConflictViability { .. })), + "ConflictViability must be checked before RelationshipCoherence, got: {:?}", + result + ); +} + +// --------------------------------------------------------------------------- +// #108 — Cross-template triangle generation +// --------------------------------------------------------------------------- + +/// Two instantiated templates produce 1 cross-template TriangleState +/// with role assignments spanning both templates. +/// +/// Spec: D-024 ("1 cross-template" requirement), D-025 (ownership model) +#[test] +fn triangle_validation_cross_template_spans_two_templates() { + let mut world = bevy_ecs::world::World::new(); + let mut rng = SimRng::new(42); + + let hub_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); + let bar_id = TemplateId::from_seed_and_slug(42, "last-shift-bar"); + + // Logistics hub roles + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + + // Bar roles + spawn_template_npc(&mut world, bar_id, "bartender", 3); + + // Cross-template triangle: ops-manager (hub) + freight-handler (hub) + bartender (bar) + let def = valid_triangle_def(999); + let overridden = TriangleDef { + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("bartender"), + ], + ..def + }; + + let result = generate_cross_template_triangles( + &mut world, + hub_id, + bar_id, + &[overridden], + &mut rng, + ); + + assert!( + result.warnings.is_empty(), + "no warnings expected for valid cross-template triangle: {:?}", + result.warnings + ); + assert_eq!( + result.triangles.len(), + 1, + "should generate exactly 1 cross-template triangle" + ); + + let state = &result.triangles[0]; + assert_eq!( + state.template_id, hub_id, + "cross-template triangle must be owned by template_a (hub)" + ); + assert_eq!(state.role_assignments.len(), 3); + + // Verify role assignments span both templates + let ops = state.role_assignments[&RoleId::new("ops-manager")]; + let freight = state.role_assignments[&RoleId::new("freight-handler")]; + let bartender = state.role_assignments[&RoleId::new("bartender")]; + assert_eq!(ops, StableId(1), "ops-manager must map to hub NPC 1"); + assert_eq!(freight, StableId(2), "freight-handler must map to hub NPC 2"); + assert_eq!(bartender, StableId(3), "bartender must map to bar NPC 3"); + + assert_eq!(state.phase, TrianglePhase::Simmering); + assert!(state.tension >= 5 && state.tension <= 25, "tension in seeded range"); + assert!(state.tension_rate >= 1 && state.tension_rate <= 5, "rate in seeded range"); +} + +/// Cross-template generation skips defs that fail validation, adding a warning. +#[test] +fn triangle_validation_cross_template_skips_invalid_defs() { + let mut world = bevy_ecs::world::World::new(); + let mut rng = SimRng::new(42); + + let hub_id = TemplateId::from_seed_and_slug(1, "hub"); + let bar_id = TemplateId::from_seed_and_slug(1, "bar"); + + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + spawn_template_npc(&mut world, bar_id, "bartender", 3); + + // Invalid def: no Want axis (fails conflict viability) + let invalid = TriangleDef { + triangle_id: TriangleId(50), + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("bartender"), + ], + conflict_type: ConflictType::LatentTension, + interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], + relationship_constraints: vec![RelationshipConstraint { + with_role: RoleId::new("freight-handler"), + kind: RelationshipKind::Colleague, + required_trust: TrustRange { min: 0, max: 3 }, + }], + }; + + let result = generate_cross_template_triangles(&mut world, hub_id, bar_id, &[invalid], &mut rng); + + assert_eq!(result.triangles.len(), 0, "invalid def must be skipped"); + assert_eq!(result.warnings.len(), 1, "exactly one warning for the skipped def"); + assert!( + result.warnings[0].contains("validation failed"), + "warning must mention validation failure: {}", + result.warnings[0] + ); +} + +/// Cross-template generation is deterministic for the same seed (D-010). +#[test] +fn triangle_validation_cross_template_deterministic() { + let hub_id = TemplateId::from_seed_and_slug(42, "hub"); + let bar_id = TemplateId::from_seed_and_slug(42, "bar"); + let def = valid_triangle_def(1); + + let make_world = || { + let mut world = bevy_ecs::world::World::new(); + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + spawn_template_npc(&mut world, bar_id, "inspector", 3); + world + }; + + let overridden = TriangleDef { + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("inspector"), + ], + ..def.clone() + }; + + let mut world1 = make_world(); + let result1 = generate_cross_template_triangles( + &mut world1, + hub_id, + bar_id, + &[overridden.clone()], + &mut SimRng::new(42), + ); + + let mut world2 = make_world(); + let result2 = generate_cross_template_triangles( + &mut world2, + hub_id, + bar_id, + &[overridden], + &mut SimRng::new(42), + ); + + assert_eq!(result1.triangles.len(), 1); + assert_eq!(result2.triangles.len(), 1); + assert_eq!( + result1.triangles[0].tension, + result2.triangles[0].tension, + "cross-template generation must be deterministic (D-010)" + ); + assert_eq!( + result1.triangles[0].tension_rate, + result2.triangles[0].tension_rate + ); +} + +/// D-024: cross-template generation is separate from intra-template generation. +/// Intra-template only sees its own template's NPCs. +#[test] +fn triangle_validation_intra_template_does_not_see_other_template_npcs() { + let mut world = bevy_ecs::world::World::new(); + let mut rng = SimRng::new(42); + + let hub_id = TemplateId::from_seed_and_slug(1, "hub"); + let bar_id = TemplateId::from_seed_and_slug(1, "bar"); + + // Only hub NPCs for roles ops-manager, freight-handler + spawn_template_npc(&mut world, hub_id, "ops-manager", 1); + spawn_template_npc(&mut world, hub_id, "freight-handler", 2); + // Bar NPC exists but should NOT be used by intra-template hub generation + spawn_template_npc(&mut world, bar_id, "inspector", 3); + + // Triangle requiring ops-manager + freight-handler + inspector + // intra-template hub generation cannot find "inspector" in hub NPCs + let def = TriangleDef { + triangle_id: TriangleId(5), + roles: [ + RoleId::new("ops-manager"), + RoleId::new("freight-handler"), + RoleId::new("inspector"), + ], + conflict_type: ConflictType::LoyaltyConflict, + interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], + relationship_constraints: vec![], + }; + + let result = generate_intra_template_triangles(&mut world, hub_id, &[def, valid_triangle_def(6)], &mut rng); + + // The def needing "inspector" should fall back (inspector is in bar, not hub) + // At least one warning about the missing role + assert!( + !result.warnings.is_empty(), + "intra-template must not find bar NPCs — warning expected for missing 'inspector' role" + ); +} diff --git a/tests/run-all b/tests/run-all new file mode 100755 index 000000000..b296cfb22 --- /dev/null +++ b/tests/run-all @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# tests/run-all: Run all test suites in order (D-030) +# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration. +# Exit: 0 = all suites pass, non-zero = any suite failed +# Stdout: {"suite":"all","total":N,"passed":N,"failed":N,"duration_ms":N,"suites":[...]} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PASS_ARGS=() +[[ -n "$FILTER" ]] && PASS_ARGS+=(--filter "$FILTER") + +SUITES=( + run-rust + run-godot + run-ipc-fixtures + run-ipc-protocol + run-ipc-integration +) + +START_MS=$(date +%s%3N) + +OVERALL_TOTAL=0 +OVERALL_PASSED=0 +OVERALL_FAILED=0 +OVERALL_EXIT=0 +SUITE_RESULTS="" + +for suite in "${SUITES[@]}"; do + script="$SCRIPT_DIR/$suite" + if [[ ! -x "$script" ]]; then + echo "Warning: $script not found or not executable — skipping" >&2 + continue + fi + + SUITE_OUT=$(mktemp) + set +e + "$script" "${PASS_ARGS[@]}" >"$SUITE_OUT" + SUITE_EXIT=$? + set -e + + SUITE_JSON=$(cat "$SUITE_OUT") + rm -f "$SUITE_OUT" + + # Accumulate totals from the suite's JSON output + S_TOTAL=$(echo "$SUITE_JSON" | grep -oE '"total":[0-9]+' | grep -oE '[0-9]+' || echo 0) + S_PASSED=$(echo "$SUITE_JSON" | grep -oE '"passed":[0-9]+' | grep -oE '[0-9]+' || echo 0) + S_FAILED=$(echo "$SUITE_JSON" | grep -oE '"failed":[0-9]+' | grep -oE '[0-9]+' || echo 0) + + OVERALL_TOTAL=$(( OVERALL_TOTAL + ${S_TOTAL:-0} )) + OVERALL_PASSED=$(( OVERALL_PASSED + ${S_PASSED:-0} )) + OVERALL_FAILED=$(( OVERALL_FAILED + ${S_FAILED:-0} )) + [[ $SUITE_EXIT -ne 0 ]] && OVERALL_EXIT=1 + + # Build suites array for JSON + if [[ -n "$SUITE_RESULTS" ]]; then + SUITE_RESULTS="$SUITE_RESULTS,$SUITE_JSON" + else + SUITE_RESULTS="$SUITE_JSON" + fi +done + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +printf '{"suite":"all","total":%d,"passed":%d,"failed":%d,"duration_ms":%d,"suites":[%s]}\n' \ + "$OVERALL_TOTAL" "$OVERALL_PASSED" "$OVERALL_FAILED" "$DURATION_MS" "$SUITE_RESULTS" +exit $OVERALL_EXIT diff --git a/tests/run-godot b/tests/run-godot new file mode 100755 index 000000000..ee323e275 --- /dev/null +++ b/tests/run-godot @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# tests/run-godot: Run Godot client test suite via gdUnit4 (D-030) +# Exit: 0 = all pass, non-zero = failure +# Stdout: {"suite":"godot","total":N,"passed":N,"failed":N,"duration_ms":N} +# +# --filter: accepts a test filename stem (e.g. "test_protocol" → runs test_protocol.gd only) +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "") +if [[ -z "$GODOT" ]]; then + printf '{"suite":"godot","total":0,"passed":0,"failed":0,"duration_ms":0,"error":"godot not found in PATH"}\n' + exit 1 +fi + +# Resolve the test target: directory or specific file +if [[ -n "$FILTER" ]]; then + # Support bare name (test_protocol) or full path (test_protocol.gd) + if [[ "$FILTER" == res://* ]]; then + TEST_TARGET="$FILTER" + elif [[ "$FILTER" == *.gd ]]; then + TEST_TARGET="res://tests/$FILTER" + else + TEST_TARGET="res://tests/${FILTER}.gd" + fi +else + TEST_TARGET="res://tests/" +fi + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +set +e +"$GODOT" --headless --path "$REPO_ROOT/client" \ + -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ + --ignoreHeadlessMode \ + -c \ + -a "$TEST_TARGET" \ + 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oiE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +# gdUnit4 outputs per-suite statistics: "N test cases | X errors | Y failures | ..." +# and a summary: "Executed test cases : (X/N)" or "Executed test cases : (X/N), Z skipped" +TOTAL=0; PASSED=0; FAILED=0 + +# Sum errors + failures across all suite statistics lines +STATS_LINES=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$TMPOUT" || true) +if [[ -n "$STATS_LINES" ]]; then + TOTAL=$(echo "$STATS_LINES" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + ERRORS=$(echo "$STATS_LINES" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + FAILURES=$(echo "$STATS_LINES" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + FAILED=$(( ${ERRORS:-0} + ${FAILURES:-0} )) + PASSED=$(( TOTAL - FAILED )) +fi + +# Fallback: parse "Executed test cases : (X/N)" for total if stats parse failed +if [[ "$TOTAL" -eq 0 ]]; then + EXEC_LINE=$(grep -oE "Executed test cases : \([0-9]+/[0-9]+\)" "$TMPOUT" | tail -1 || true) + if [[ -n "$EXEC_LINE" ]]; then + TOTAL=$(echo "$EXEC_LINE" | grep -oE '/[0-9]+\)' | grep -oE '[0-9]+') + PASSED=$(echo "$EXEC_LINE" | grep -oE '\([0-9]+/' | grep -oE '[0-9]+') + FAILED=$(( TOTAL - PASSED )) + fi +fi + +rm -f "$TMPOUT" +printf '{"suite":"godot","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-ipc-benchmark b/tests/run-ipc-benchmark new file mode 100755 index 000000000..38d4b5c0d --- /dev/null +++ b/tests/run-ipc-benchmark @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# tests/run-ipc-benchmark: IPC round-trip latency benchmark (#342, D-020) +# +# Runs server/tests/ipc_bench.rs via `cargo test --release --test ipc_bench`. +# Parses IPC_BENCH_RESULT:{json} from output and outputs the result JSON. +# +# Latency budget: p99 <= 5ms (D-020: "~1-5ms serialization latency per tick"). +# +# NOTE (#342): Handshake step is stubbed in ipc_bench.rs pending #555 (server +# protocol handshake) and #556 (client handshake). Full clean timing requires +# a working handshake before the measurement loop starts. +# +# Exit: 0 = benchmark passed (p99 within threshold), non-zero = failure +# Stdout: {"p50_ms":N,"p95_ms":N,"p99_ms":N,"threshold_ms":5,"passed":true,"rounds":100} +set -euo pipefail + +THRESHOLD_MS=5 + +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) shift 2 ;; # ignored — benchmark has no test filter + --filter=*) shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +set +e +cd "$REPO_ROOT/server" && \ + cargo test --release --test ipc_bench -- --ignored --nocapture 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +# Extract IPC_BENCH_RESULT:{json} line from output +RESULT_LINE=$(grep "^IPC_BENCH_RESULT:" "$TMPOUT" | tail -1 || true) +rm -f "$TMPOUT" + +if [[ -n "$RESULT_LINE" ]]; then + # Strip the prefix and output the JSON + echo "${RESULT_LINE#IPC_BENCH_RESULT:}" +else + # No result line — test failed to produce output + printf '{"p50_ms":0,"p95_ms":0,"p99_ms":0,"threshold_ms":%d,"passed":false,"error":"no benchmark output — server binary may not be built (run make build-server)"}\n' \ + "$THRESHOLD_MS" + EXIT_CODE=1 +fi + +exit $EXIT_CODE diff --git a/tests/run-ipc-fixtures b/tests/run-ipc-fixtures new file mode 100755 index 000000000..6d76a073e --- /dev/null +++ b/tests/run-ipc-fixtures @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# tests/run-ipc-fixtures: Layer 1 IPC fixture tests (D-030) +# Runs Rust serialization round-trip tests + GDScript fixture validation. +# GDScript side is skipped if client/tests/test_ipc_fixtures.gd doesn't exist yet (#271). +# Exit: 0 = all pass, non-zero = any failure +# Stdout: {"suite":"ipc-fixtures","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +_parse_nextest_summary() { + local tmpout="$1" + local summary total passed failed + summary=$(grep -E "^\s*(Summary|Finished)" "$tmpout" | tail -1 || true) + if [[ -n "$summary" ]]; then + total=$(_extract_num "$summary" "tests? run") + passed=$(_extract_num "$summary" "passed") + failed=$(_extract_num "$summary" "failed") + else + total=0; passed=0; failed=0 + fi + echo "$total $passed $failed" +} + +# --- Layer 1a: Rust serialization tests --- +START_MS=$(date +%s%3N) + +cd "$REPO_ROOT/server" +TMPOUT=$(mktemp) +NEXTEST_ARGS=(nextest run --color never --test serialization) +[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})") + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +RUST_EXIT=${PIPESTATUS[0]} +set -e + +read -r RUST_TOTAL RUST_PASSED RUST_FAILED < <(_parse_nextest_summary "$TMPOUT") +rm -f "$TMPOUT" + +# --- Layer 1b: GDScript fixture tests (optional until #271 lands) --- +GDS_FIXTURE="$REPO_ROOT/client/tests/test_ipc_fixtures.gd" +GDS_TOTAL=0; GDS_PASSED=0; GDS_FAILED=0; GDS_EXIT=0 + +if [[ -f "$GDS_FIXTURE" ]]; then + GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "") + if [[ -z "$GODOT" ]]; then + echo "Warning: test_ipc_fixtures.gd found but godot not in PATH — skipping GDScript layer" >&2 + else + GDTMP=$(mktemp) + set +e + "$GODOT" --headless --path "$REPO_ROOT/client" \ + -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ + --ignoreHeadlessMode -c \ + -a res://tests/test_ipc_fixtures.gd \ + 2>&1 | tee "$GDTMP" >&2 + GDS_EXIT=${PIPESTATUS[0]} + set -e + + STATS=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$GDTMP" || true) + if [[ -n "$STATS" ]]; then + GDS_TOTAL=$(echo "$STATS" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + ERRS=$(echo "$STATS" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + FAILS=$(echo "$STATS" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + GDS_FAILED=$(( ${ERRS:-0} + ${FAILS:-0} )) + GDS_PASSED=$(( GDS_TOTAL - GDS_FAILED )) + fi + rm -f "$GDTMP" + fi +fi + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +TOTAL=$(( RUST_TOTAL + GDS_TOTAL )) +PASSED=$(( RUST_PASSED + GDS_PASSED )) +FAILED=$(( RUST_FAILED + GDS_FAILED )) + +# Overall exit: fail if either side failed +EXIT_CODE=$(( RUST_EXIT != 0 || GDS_EXIT != 0 ? 1 : 0 )) + +printf '{"suite":"ipc-fixtures","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-ipc-integration b/tests/run-ipc-integration new file mode 100755 index 000000000..5f0696e71 --- /dev/null +++ b/tests/run-ipc-integration @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# tests/run-ipc-integration: Layer 3 real-subprocess integration tests (D-030) +# Spawns the server binary as a real child process, runs IPC round-trip. +# Also invokes tests/run-ipc-benchmark when that script exists (#342). +# Exit: 0 = all pass, non-zero = any failure +# Stdout: {"suite":"ipc-integration","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT/server" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +NEXTEST_ARGS=(nextest run --color never --test layer3) +[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})") + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +LAYER3_EXIT=${PIPESTATUS[0]} +set -e + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true) +TOTAL=0; PASSED=0; FAILED=0 +if [[ -n "$SUMMARY" ]]; then + TOTAL=$(_extract_num "$SUMMARY" "tests? run") + PASSED=$(_extract_num "$SUMMARY" "passed") + FAILED=$(_extract_num "$SUMMARY" "failed") +fi +rm -f "$TMPOUT" + +# Run IPC benchmark if it exists (#342 — requires handshake from #555/#556) +BENCH_SCRIPT="$REPO_ROOT/tests/run-ipc-benchmark" +BENCH_EXIT=0 +if [[ -x "$BENCH_SCRIPT" ]]; then + BENCH_ARGS=() + [[ -n "$FILTER" ]] && BENCH_ARGS+=(--filter "$FILTER") + set +e + "$BENCH_SCRIPT" "${BENCH_ARGS[@]}" >&2 + BENCH_EXIT=$? + set -e + if [[ $BENCH_EXIT -ne 0 ]]; then + FAILED=$(( FAILED + 1 )) + TOTAL=$(( TOTAL + 1 )) + else + PASSED=$(( PASSED + 1 )) + TOTAL=$(( TOTAL + 1 )) + fi +fi + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +EXIT_CODE=$(( LAYER3_EXIT != 0 || BENCH_EXIT != 0 ? 1 : 0 )) + +printf '{"suite":"ipc-integration","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-ipc-protocol b/tests/run-ipc-protocol new file mode 100755 index 000000000..166a05a53 --- /dev/null +++ b/tests/run-ipc-protocol @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# tests/run-ipc-protocol: Layer 2 mock IPC protocol tests (D-030) +# Runs LocalBridge Unix-socket round-trip tests (no real subprocess). +# Exit: 0 = all pass, non-zero = failure +# Stdout: {"suite":"ipc-protocol","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT/server" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +NEXTEST_ARGS=(nextest run --color never --test bridge_ipc) +[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})") + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true) +TOTAL=0; PASSED=0; FAILED=0 +if [[ -n "$SUMMARY" ]]; then + TOTAL=$(_extract_num "$SUMMARY" "tests? run") + PASSED=$(_extract_num "$SUMMARY" "passed") + FAILED=$(_extract_num "$SUMMARY" "failed") +fi + +rm -f "$TMPOUT" +printf '{"suite":"ipc-protocol","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-rust b/tests/run-rust new file mode 100755 index 000000000..453a00b1b --- /dev/null +++ b/tests/run-rust @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# tests/run-rust: Run Rust test suite via cargo nextest (D-030) +# Exit: 0 = all pass, non-zero = failure +# Stdout: {"suite":"rust","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT/server" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +NEXTEST_ARGS=(nextest run --color never) +if [[ -n "$FILTER" ]]; then + NEXTEST_ARGS+=(-E "test(~${FILTER})") +fi + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +# Parse nextest summary: " Summary [ 0.123s] N tests run: X passed[, Y failed], Z skipped" +# (older nextest uses "Finished", newer uses "Summary" — match both) +SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true) + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +TOTAL=0; PASSED=0; FAILED=0 +if [[ -n "$SUMMARY" ]]; then + TOTAL=$(_extract_num "$SUMMARY" "tests? run") + PASSED=$(_extract_num "$SUMMARY" "passed") + FAILED=$(_extract_num "$SUMMARY" "failed") +fi + +rm -f "$TMPOUT" +printf '{"suite":"rust","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tooling/check-decision-ids b/tooling/check-decision-ids new file mode 100755 index 000000000..298d011a8 --- /dev/null +++ b/tooling/check-decision-ids @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Pre-commit check: detect duplicate decision IDs across decisions/*.md files. +# Fails if the same D-NNN, Q-NNN, or R-NNN appears in more than one file. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +# Use the decisions_sync.py check-dupes command +RESULT=$(python3 "$REPO_ROOT/tooling/db/decisions_sync.py" check-dupes 2>&1) + +# Parse the JSON result +DUPES=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('duplicates',0))" 2>/dev/null || echo "0") +TOTAL=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('total_ids',0))" 2>/dev/null || echo "0") + +if [ "$DUPES" -gt 0 ]; then + echo "check-decision-ids: FAILED — $DUPES duplicate ID(s) found" + echo "$RESULT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +for detail in d.get('details', []): + print(f' {detail}') +" 2>/dev/null + exit 1 +fi + +echo "check-decision-ids: OK — $TOTAL unique IDs, no duplicates" +exit 0 diff --git a/db/connectors/audio-batch b/tooling/db/audio-batch similarity index 100% rename from db/connectors/audio-batch rename to tooling/db/audio-batch diff --git a/db/connectors/audio-generate b/tooling/db/audio-generate similarity index 100% rename from db/connectors/audio-generate rename to tooling/db/audio-generate diff --git a/db/connectors/audio-health b/tooling/db/audio-health similarity index 100% rename from db/connectors/audio-health rename to tooling/db/audio-health diff --git a/db/connectors/audio-post b/tooling/db/audio-post similarity index 100% rename from db/connectors/audio-post rename to tooling/db/audio-post diff --git a/db/connectors/audio_batch.py b/tooling/db/audio_batch.py similarity index 100% rename from db/connectors/audio_batch.py rename to tooling/db/audio_batch.py diff --git a/db/connectors/audio_connector.py b/tooling/db/audio_connector.py similarity index 100% rename from db/connectors/audio_connector.py rename to tooling/db/audio_connector.py diff --git a/db/connectors/audio_post.py b/tooling/db/audio_post.py similarity index 100% rename from db/connectors/audio_post.py rename to tooling/db/audio_post.py diff --git a/db/connectors/config.json b/tooling/db/config.json similarity index 100% rename from db/connectors/config.json rename to tooling/db/config.json diff --git a/tooling/db/decision b/tooling/db/decision new file mode 100755 index 000000000..82492f616 --- /dev/null +++ b/tooling/db/decision @@ -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" "$@" diff --git a/db/connectors/decisions-sync b/tooling/db/decisions-sync similarity index 100% rename from db/connectors/decisions-sync rename to tooling/db/decisions-sync diff --git a/db/connectors/decisions_sync.py b/tooling/db/decisions_sync.py similarity index 71% rename from db/connectors/decisions_sync.py rename to tooling/db/decisions_sync.py index d32df5cc7..1e24bc23c 100644 --- a/db/connectors/decisions_sync.py +++ b/tooling/db/decisions_sync.py @@ -23,11 +23,11 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" -SCHEMA_PATH = SCRIPT_DIR.parent / "schema.sql" -WORKTREE_ROOT = SCRIPT_DIR.parent.parent +WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() +SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" DECISIONS_DIR = WORKTREE_ROOT / "decisions" -# Shared database lives in the parent of all worktrees (three levels up from db/connectors/). -DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve() +# Shared database lives in the parent of all worktrees (three levels up from tooling/db/). +DB_PATH = (WORKTREE_ROOT / ".." / "settledreach.db").resolve() # --------------------------------------------------------------------------- # Config / DB (same pattern as sqlite_connector.py) @@ -366,22 +366,133 @@ def sync(cfg): conn.close() +# --------------------------------------------------------------------------- +# ID claiming — database is authority for ID allocation +# --------------------------------------------------------------------------- + + +def next_id(cfg, prefix=None): + """Return the next available ID for a given prefix (D, Q, R) or all.""" + conn = get_connection(cfg) + try: + result = {} + prefixes = [prefix.upper()] if prefix else ["D", "Q", "R"] + for p in prefixes: + # Check both DB and markdown files for the highest ID + row = conn.execute( + "SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as max_num " + "FROM decisions WHERE id LIKE ?", + (f"{p}-%",), + ).fetchone() + db_max = row["max_num"] if row and row["max_num"] else 0 + + # Also scan markdown files in case they're ahead of the DB + md_max = 0 + for filepath in sorted(DECISIONS_DIR.glob("*.md")): + if filepath.name.lower() == "readme.md": + continue + text = filepath.read_text(encoding="utf-8") + for m in re.finditer(rf"^###\s+{p}-(\d{{3}}):", text, re.MULTILINE): + num = int(m.group(1)) + if num > md_max: + md_max = num + + highest = max(db_max, md_max) + next_num = highest + 1 + result[p] = f"{p}-{next_num:03d}" + + return {"ok": True, **result} + finally: + conn.close() + + +def claim_id(cfg, prefix, domain, title): + """Claim the next available ID and insert a placeholder into the DB.""" + if prefix not in ("D", "Q", "R"): + return {"ok": False, "error": f"Invalid prefix: {prefix}. Must be D, Q, or R."} + + type_map = {"D": "confirmed", "Q": "question", "R": "rejected"} + status_map = {"D": "active", "Q": "open", "R": "active"} + + nxt = next_id(cfg, prefix) + if not nxt.get("ok"): + return nxt + + new_id = nxt[prefix] + conn = get_connection(cfg) + try: + conn.execute( + """INSERT INTO decisions (id, type, domain, title, status, file_path, synced_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""", + (new_id, type_map[prefix], domain, title, status_map[prefix], + f"decisions/{domain}.md"), + ) + conn.commit() + return {"ok": True, "id": new_id, "domain": domain, "title": title} + except sqlite3.IntegrityError as exc: + conn.rollback() + return {"ok": False, "error": f"ID conflict: {exc}"} + finally: + conn.close() + + +def check_dupes(cfg): + """Check for duplicate decision IDs across all markdown files.""" + # Pre-existing collisions too deeply embedded to renumber (139+ references). + # New collisions are prevented by the claim workflow. + KNOWN_EXCEPTIONS = {"D-035"} + + id_locations = {} # id -> [(file, line_number)] + warnings = [] + + for filepath in sorted(DECISIONS_DIR.glob("*.md")): + if filepath.name.lower() == "readme.md": + continue + text = filepath.read_text(encoding="utf-8") + for i, line in enumerate(text.split("\n"), 1): + m = HEADING_RE.match(line) + if m: + did = m.group(1) + if did not in id_locations: + id_locations[did] = [] + id_locations[did].append((filepath.name, i)) + + dupes = {did: locs for did, locs in id_locations.items() + if len(locs) > 1 and did not in KNOWN_EXCEPTIONS} + + if dupes: + for did, locs in sorted(dupes.items()): + loc_str = ", ".join(f"{f}:{ln}" for f, ln in locs) + warnings.append(f"DUPLICATE {did}: {loc_str}") + + return { + "ok": len(dupes) == 0, + "total_ids": len(id_locations), + "duplicates": len(dupes), + "known_exceptions": list(KNOWN_EXCEPTIONS), + "details": warnings, + } + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- HELP_TEXT = """\ -Commonwealth Decisions Sync +Commonwealth Decisions Sync & ID Management Usage: - decisions_sync.py sync Parse decisions/*.md and upsert into SQLite - decisions_sync.py --help Show this help message + decisions_sync.py sync Parse decisions/*.md and upsert into SQLite + decisions_sync.py next [D|Q|R] Show next available ID (all prefixes or one) + decisions_sync.py claim <D|Q|R> <domain> [title] Claim next ID and insert placeholder + decisions_sync.py check-dupes Check for duplicate IDs across markdown files + decisions_sync.py --help Show this help message -Parses all markdown files in decisions/ (excluding README.md), extracts -decision blocks (D-NNN, Q-NNN, R-NNN), and syncs them into the decisions -and decision_refs tables. - -Idempotent: safe to run repeatedly. References are rebuilt on every sync. +ID claiming workflow: + 1. Agent calls 'claim D architecture "Per-game save dirs"' + 2. Gets back D-085 (or whatever is next) + 3. Agent writes D-085 in the appropriate domain file + 4. Pre-commit hook runs check-dupes to catch collisions Config: {config} Schema: {schema} @@ -404,6 +515,18 @@ def main(): if cmd == "sync": result = sync(cfg) + elif cmd == "next": + result = next_id(cfg, sys.argv[2] if len(sys.argv) > 2 else None) + elif cmd == "claim": + if len(sys.argv) < 4: + result = {"ok": False, "error": "Usage: claim <D|Q|R> <domain> [title]"} + else: + prefix = sys.argv[2].upper() + domain = sys.argv[3] + title = " ".join(sys.argv[4:]) if len(sys.argv) > 4 else "(unclaimed)" + result = claim_id(cfg, prefix, domain, title) + elif cmd == "check-dupes": + result = check_dupes(cfg) else: result = {"ok": False, "error": f"Unknown command: {cmd}. Use --help for usage."} diff --git a/db/connectors/qdrant-count b/tooling/db/qdrant-count similarity index 100% rename from db/connectors/qdrant-count rename to tooling/db/qdrant-count diff --git a/db/connectors/qdrant-health b/tooling/db/qdrant-health similarity index 100% rename from db/connectors/qdrant-health rename to tooling/db/qdrant-health diff --git a/db/connectors/qdrant-index b/tooling/db/qdrant-index similarity index 100% rename from db/connectors/qdrant-index rename to tooling/db/qdrant-index diff --git a/db/connectors/qdrant-search b/tooling/db/qdrant-search similarity index 100% rename from db/connectors/qdrant-search rename to tooling/db/qdrant-search diff --git a/db/connectors/qdrant_connector.py b/tooling/db/qdrant_connector.py similarity index 100% rename from db/connectors/qdrant_connector.py rename to tooling/db/qdrant_connector.py diff --git a/db/connectors/sprint b/tooling/db/sprint similarity index 98% rename from db/connectors/sprint rename to tooling/db/sprint index ff91da9ae..0eae01517 100755 --- a/db/connectors/sprint +++ b/tooling/db/sprint @@ -23,13 +23,14 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent TICKET_CLI = str(SCRIPT_DIR / "ticket") -DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve() -PROJECT_ROOT = (SCRIPT_DIR / ".." / "..").resolve() +WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() +DB_PATH = (WORKTREE_ROOT / ".." / "settledreach.db").resolve() +PROJECT_ROOT = WORKTREE_ROOT REMINDER = """--- Reminder: Keep ticket status up to date after finishing work. - db/connectors/ticket status <id> in_progress (when starting) - db/connectors/ticket status <id> done (when finished)""" + tooling/db/ticket status <id> in_progress (when starting) + tooling/db/ticket status <id> done (when finished)""" def run_ticket(*args): @@ -559,19 +560,19 @@ def cmd_sweep(args): issues.append({ "type": "unassigned_in_progress", "detail": f"#{t['id']} unassigned {t['status']}", - "fix": f"db/connectors/ticket assign {t['id']} <agent>", + "fix": f"tooling/db/ticket assign {t['id']} <agent>", }) if t["status"] == "backlog" and sprint["status"] == "active" and t["id"] not in blocked_by_map: issues.append({ "type": "stale_backlog", "detail": f"#{t['id']} stale backlog", - "fix": f"db/connectors/ticket status {t['id']} in_progress", + "fix": f"tooling/db/ticket status {t['id']} in_progress", }) if t["status"] == "done" and t.get("assigned_to"): issues.append({ "type": "assigned_but_done", "detail": f"#{t['id']} done, still assigned", - "fix": f"db/connectors/ticket unassign {t['id']}", + "fix": f"tooling/db/ticket unassign {t['id']}", }) # Progress diff --git a/db/connectors/sqlite-exec b/tooling/db/sqlite-exec similarity index 100% rename from db/connectors/sqlite-exec rename to tooling/db/sqlite-exec diff --git a/db/connectors/sqlite-init b/tooling/db/sqlite-init similarity index 100% rename from db/connectors/sqlite-init rename to tooling/db/sqlite-init diff --git a/db/connectors/sqlite-query b/tooling/db/sqlite-query similarity index 100% rename from db/connectors/sqlite-query rename to tooling/db/sqlite-query diff --git a/db/connectors/sqlite-seed b/tooling/db/sqlite-seed similarity index 100% rename from db/connectors/sqlite-seed rename to tooling/db/sqlite-seed diff --git a/db/connectors/sqlite_connector.py b/tooling/db/sqlite_connector.py similarity index 97% rename from db/connectors/sqlite_connector.py rename to tooling/db/sqlite_connector.py index d6f609f25..e943bb379 100755 --- a/db/connectors/sqlite_connector.py +++ b/tooling/db/sqlite_connector.py @@ -22,9 +22,10 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" -SCHEMA_PATH = SCRIPT_DIR.parent / "schema.sql" -# Shared database lives in the parent of all worktrees (three levels up from db/connectors/). -DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve() +WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() +SCHEMA_PATH = WORKTREE_ROOT / "db" / "schema.sql" +# Shared database lives in the parent of all worktrees (three levels up from tooling/db/). +DB_PATH = (WORKTREE_ROOT / ".." / "settledreach.db").resolve() def load_config(): diff --git a/db/connectors/ticket b/tooling/db/ticket similarity index 99% rename from db/connectors/ticket rename to tooling/db/ticket index f9797b868..04028e02f 100755 --- a/db/connectors/ticket +++ b/tooling/db/ticket @@ -30,8 +30,9 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_PATH = SCRIPT_DIR / "config.json" -# Shared database lives in the parent of all worktrees (three levels up from db/connectors/). -DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve() +WORKTREE_ROOT = (SCRIPT_DIR / ".." / "..").resolve() +# Shared database lives in the parent of all worktrees (three levels up from tooling/db/). +DB_PATH = (WORKTREE_ROOT / ".." / "settledreach.db").resolve() def load_config(): diff --git a/tooling/synth_ui_sounds.py b/tooling/synth_ui_sounds.py index 361bcab1f..80f0d1beb 100644 --- a/tooling/synth_ui_sounds.py +++ b/tooling/synth_ui_sounds.py @@ -203,4 +203,4 @@ if __name__ == "__main__": monologue_chime_urgent() print() - print("Done. Convert with: db/connectors/audio-post convert <file.wav>") + print("Done. Convert with: tooling/db/audio-post convert <file.wav>")