Compare commits
62
Commits
fcd2fd3cf7
...
0ab81961dc
@@ -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 <path>`.
|
||||
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `tooling/db/qdrant-index <path>`.
|
||||
|
||||
## Team workflow (mandatory)
|
||||
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 *)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 <id> review
|
||||
tooling/db/ticket status <id> review
|
||||
```
|
||||
|
||||
Report which tickets were moved to review. Skip tickets that are
|
||||
|
||||
@@ -83,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/<branch>/
|
||||
/var/mnt/data/projects/settled-reach/<branch>/
|
||||
```
|
||||
|
||||
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
|
||||
@@ -103,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/<branch>/
|
||||
out at: /var/mnt/data/projects/settled-reach/<branch>/
|
||||
|
||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
```
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||
|
||||
@@ -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/<branch>/`
|
||||
`/var/mnt/data/projects/settled-reach/<branch>/`
|
||||
|
||||
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
|
||||
|
||||
@@ -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 <epic_id>
|
||||
tooling/db/ticket children <epic_id>
|
||||
```
|
||||
|
||||
Use `db/connectors/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
Use `tooling/db/ticket show --brief <id> [<id>...]` 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 <ticket_id> <sprint_id>
|
||||
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
|
||||
|---|-------|------------|
|
||||
| #ID | Title | #dependency or — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
|
||||
@@ -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 <id>
|
||||
tooling/db/ticket show <id>
|
||||
```
|
||||
|
||||
### 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 <id> in_progress
|
||||
tooling/db/ticket status <id> 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 <id>` for full ticket specs.",
|
||||
Use `tooling/db/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
run_in_background: true
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ the workflow.
|
||||
Run these two commands in parallel:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint sweep
|
||||
tooling/db/sprint sweep
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 <type> <title> [--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
|
||||
|
||||
@@ -6,8 +6,53 @@ 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)
|
||||
@@ -17,6 +62,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- 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)
|
||||
@@ -75,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
|
||||
|
||||
@@ -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,19 +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 ..."` | — |
|
||||
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
| 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:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- **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
|
||||
|
||||
@@ -284,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 ---
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ notifications:
|
||||
|
||||
# System
|
||||
save_complete: "Progress saved."
|
||||
load_complete: "Session restored."
|
||||
save_failed: "Save failed."
|
||||
load_failed: "Load failed."
|
||||
connection_lost: "Signal interrupted."
|
||||
connection_restored: "Signal restored."
|
||||
|
||||
@@ -170,6 +173,8 @@ dialogue:
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
new_game: "New Game"
|
||||
continue: "Continue"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
|
||||
+12
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="The Settled Reach"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
@@ -23,6 +23,7 @@ InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
@@ -135,6 +136,16 @@ teleport_hub={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
quicksave={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
quickload={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
|
||||
|
||||
; Main menu — New Game / Continue / Quit.
|
||||
; #258: D-085 per-game save directory created on New Game.
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_mainmenu")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -120.0
|
||||
offset_top = -80.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 100.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "THE SETTLED REACH"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 36
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
|
||||
[node name="NewGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "NEW GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="ContinueBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "CONTINUE"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "QUIT"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
@@ -1,9 +1,19 @@
|
||||
extends Node
|
||||
|
||||
signal game_id_changed(new_id: String)
|
||||
|
||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||
# Tiles use format: [{x, y, z, type}].
|
||||
var current_snapshot: Dictionary = {}
|
||||
|
||||
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||
# Set by SessionManager.new_game() or SessionManager.resume_game().
|
||||
# Empty string when no session is active (main menu state).
|
||||
var current_game_id: String = "":
|
||||
set(v):
|
||||
current_game_id = v
|
||||
game_id_changed.emit(v)
|
||||
var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
@@ -60,6 +70,11 @@ var insert_active: bool = true
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
# v15 fields (#554, D-085): save/load result from server.
|
||||
# {success: bool, kind: "save"|"load", error: Variant} or null.
|
||||
# One-shot: consumed by main.gd after display, then set back to null.
|
||||
var save_result: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
@@ -94,14 +109,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:
|
||||
@@ -125,12 +143,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"
|
||||
@@ -269,21 +294,35 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
current_examine_result = null
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
|
||||
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||
save_result = snapshot.save_result
|
||||
else:
|
||||
save_result = null
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
|
||||
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
|
||||
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
|
||||
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 ""
|
||||
# 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)
|
||||
|
||||
@@ -22,6 +22,8 @@ enum Action {
|
||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
@@ -112,12 +114,24 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
elif event.is_action_pressed("quicksave"):
|
||||
action = Action.SAVE_GAME
|
||||
elif event.is_action_pressed("quickload"):
|
||||
action = Action.LOAD_GAME
|
||||
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
var entry := {
|
||||
"action": action,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
}
|
||||
# #554: Attach save path for SaveGame/LoadGame actions
|
||||
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
|
||||
var game_id := GameState.current_game_id
|
||||
if game_id.is_empty():
|
||||
get_viewport().set_input_as_handled()
|
||||
return # No active session — ignore save/load
|
||||
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
|
||||
input_queue.append(entry)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
extends Node
|
||||
## D-085 (#258): Game session lifecycle manager.
|
||||
## Creates per-game save directories on New Game, resumes existing sessions,
|
||||
## and handles quit-to-menu flow with save confirmation.
|
||||
##
|
||||
## All save dirs live under user://saves/<game-id>/ where game-id is
|
||||
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
|
||||
|
||||
const SAVES_DIR := "user://saves/"
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
const MENU_SCENE := "res://scenes/main_menu.tscn"
|
||||
|
||||
var _quit_dialog: ConfirmationDialog = null
|
||||
|
||||
|
||||
## Generate a new game-id, create its save directory, and activate the session.
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
|
||||
func list_game_dirs() -> Array:
|
||||
var dir := DirAccess.open(SAVES_DIR)
|
||||
if dir == null:
|
||||
return []
|
||||
var results: Array = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not entry.begins_with("."):
|
||||
var dir_path := SAVES_DIR + entry + "/"
|
||||
var newest_save := _find_newest_save(dir_path)
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
return results
|
||||
|
||||
|
||||
## Show "Save before quitting?" confirmation dialog, then return to main menu.
|
||||
## #554: The actual F5 save will be wired here once server supports SaveCommand.
|
||||
func quit_to_menu() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
return # Dialog already open
|
||||
_quit_dialog = ConfirmationDialog.new()
|
||||
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
|
||||
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
|
||||
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
|
||||
get_tree().root.add_child(_quit_dialog)
|
||||
_quit_dialog.confirmed.connect(_do_quit_to_menu)
|
||||
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
|
||||
_quit_dialog.popup_centered()
|
||||
|
||||
|
||||
func _do_quit_to_menu() -> void:
|
||||
_cleanup_quit_dialog()
|
||||
# #554: Trigger quicksave before navigating to menu.
|
||||
# send_input() buffers the command — defer scene change by one frame so
|
||||
# SimBridge._process() flushes the outbound buffer before teardown.
|
||||
if not GameState.current_game_id.is_empty():
|
||||
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
})
|
||||
GameState.current_game_id = ""
|
||||
_navigate_to_menu.call_deferred()
|
||||
else:
|
||||
GameState.current_game_id = ""
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _navigate_to_menu() -> void:
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _cleanup_quit_dialog() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
_quit_dialog.queue_free()
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return ""
|
||||
var best_name := ""
|
||||
var best_time: int = 0
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if not dir.current_is_dir() and entry.ends_with(".sav"):
|
||||
var mtime := FileAccess.get_modified_time(dir_path + entry)
|
||||
if mtime > best_time:
|
||||
best_time = mtime
|
||||
best_name = entry
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return best_name
|
||||
@@ -5,13 +5,7 @@ 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
|
||||
|
||||
@@ -39,17 +33,56 @@ 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:
|
||||
@@ -72,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)
|
||||
@@ -233,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:
|
||||
@@ -245,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
|
||||
@@ -271,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
|
||||
|
||||
@@ -315,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.
|
||||
@@ -323,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:
|
||||
@@ -349,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+107
-83
@@ -33,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||
var _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
|
||||
|
||||
@@ -65,11 +66,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
|
||||
@@ -89,89 +130,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)
|
||||
if minimap and minimap.has_method("set_insert_active"):
|
||||
minimap.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
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()
|
||||
|
||||
# #264: Update journal panel — auto-close on dialogue, refresh if open
|
||||
if journal_panel and journal_panel.has_method("update_from_state"):
|
||||
journal_panel.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
# 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()
|
||||
|
||||
# #174: Show examine result if server sent one this tick (#242)
|
||||
_consume_examine_result()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
# 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.
|
||||
@@ -244,6 +205,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).
|
||||
@@ -381,6 +368,27 @@ func _consume_dialogue_response() -> void:
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# #554: Show save/load result notification from server response.
|
||||
func _consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null # consume once
|
||||
var msg: String
|
||||
if result.get("success", false):
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_complete")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_complete")
|
||||
else:
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_failed")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_failed")
|
||||
if monologue_display:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
@@ -432,6 +440,22 @@ func _on_dialogue_dismissed() -> void:
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
|
||||
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
|
||||
func _on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
|
||||
func _on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
|
||||
func _on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 14
|
||||
const PROTOCOL_VERSION: int = 16
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -207,7 +207,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
})
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords.
|
||||
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||
var poi_list: Array = []
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
@@ -219,7 +219,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"category": str(raw_poi.get("category", "Location")),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
@@ -233,6 +233,35 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
||||
}
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load operation result.
|
||||
# {success: bool, kind: "save"|"load", error: String|null}
|
||||
var save_result: Variant = null
|
||||
var raw_save: Variant = raw.get("save_result")
|
||||
if raw_save is Dictionary:
|
||||
save_result = {
|
||||
"success": bool(raw_save.get("success", false)),
|
||||
"kind": str(raw_save.get("kind", "")),
|
||||
"error": raw_save.get("error"),
|
||||
}
|
||||
|
||||
# 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}]}
|
||||
@@ -290,6 +319,9 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"poi_list": poi_list,
|
||||
"examine_result": examine_result,
|
||||
"player_knowledge": player_knowledge,
|
||||
"save_result": save_result,
|
||||
"stationary_ticks": stationary_ticks,
|
||||
"zone_id": zone_id,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
class_name TestHarness
|
||||
extends RefCounted
|
||||
## Standalone test simulation for client development without a running server.
|
||||
## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC
|
||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||
## boundary (no game logic in the production client autoload).
|
||||
|
||||
var tick: int = 0
|
||||
var player_pos: Vector2i = Vector2i(10, 10)
|
||||
var facing: String = "North"
|
||||
var input_queue: Array = []
|
||||
var in_dialogue: bool = false
|
||||
var gauntlet_mode: bool = false
|
||||
var npc_relationship: String = "Unknown"
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
tick = 0
|
||||
player_pos = Vector2i(10, 10)
|
||||
facing = "North"
|
||||
input_queue.clear()
|
||||
in_dialogue = false
|
||||
gauntlet_mode = false
|
||||
npc_relationship = "Unknown"
|
||||
|
||||
|
||||
func process_input(action_name: String) -> void:
|
||||
input_queue.append(action_name)
|
||||
|
||||
|
||||
func process_facing(new_facing: String) -> void:
|
||||
facing = new_facing
|
||||
|
||||
|
||||
# -- Snapshot generation -------------------------------------------------------
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
player_pos = Vector2i(10, 10)
|
||||
in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and has_los(player_pos, npc_pos):
|
||||
in_dialogue = true
|
||||
continue
|
||||
var delta := action_to_delta(action_name)
|
||||
var new_pos := player_pos + delta
|
||||
if _is_walkable(new_pos):
|
||||
player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
if in_dialogue:
|
||||
in_dialogue = false
|
||||
input_queue.clear()
|
||||
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062)
|
||||
var dialogue: Variant = null
|
||||
if in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060)
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if tick >= conv_start and tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _tiles(),
|
||||
"visible_tiles": _visible_tiles(),
|
||||
"visible_positions": _visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
"save_result": null,
|
||||
}
|
||||
|
||||
|
||||
# -- Map generation ------------------------------------------------------------
|
||||
|
||||
func _tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
|
||||
func _visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||
return vtiles
|
||||
|
||||
|
||||
func _visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
const _WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
|
||||
func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
|
||||
static func action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
static func delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name SnapshotEventRouter
|
||||
## Routes snapshot fields to registered handlers (D-020, #559).
|
||||
##
|
||||
## Decouples main.gd from knowing which child node handles which snapshot field.
|
||||
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
|
||||
##
|
||||
## Two handler types:
|
||||
## - Keyed: called only when the snapshot contains a specific field.
|
||||
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
|
||||
##
|
||||
## All handlers are zero-argument callables — they read from GameState directly.
|
||||
## This preserves GameState as the single source of truth post-apply_snapshot().
|
||||
|
||||
## Keyed handlers: field_name → Array[Callable]
|
||||
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
|
||||
var _keyed: Dictionary = {} # String → Array[Callable]
|
||||
|
||||
## Always handlers: called every dispatch regardless of snapshot content.
|
||||
var _always: Array[Callable] = []
|
||||
|
||||
|
||||
## Register a handler for a specific snapshot field key.
|
||||
## Handler is called (with no arguments) when snapshot.has(field) is true.
|
||||
## Multiple handlers per field are supported — they run in registration order.
|
||||
func register(field: String, handler: Callable) -> void:
|
||||
if not _keyed.has(field):
|
||||
_keyed[field] = []
|
||||
_keyed[field].append(handler)
|
||||
|
||||
|
||||
## Register a handler that runs every dispatch tick (not keyed to a field).
|
||||
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
|
||||
func register_always(handler: Callable) -> void:
|
||||
_always.append(handler)
|
||||
|
||||
|
||||
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
|
||||
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
|
||||
func dispatch(snapshot: Dictionary) -> void:
|
||||
for handler in _always:
|
||||
handler.call()
|
||||
for field in _keyed:
|
||||
if snapshot.has(field):
|
||||
for handler in _keyed[field]:
|
||||
handler.call()
|
||||
@@ -1,5 +1,15 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
## #348: F3 debug overlay — real-time visualization of game state for dev use.
|
||||
## Dev-only: disabled entirely in export builds (OS.is_debug_build() = false).
|
||||
##
|
||||
## Panels:
|
||||
## 1. Stats text (top-left): tick, pos, fps, etc.
|
||||
## 2. World overlays (over game): LOS rays, vision cone, NPC paths, info tags
|
||||
## 3. Tick timing graph (bottom-left): last-30-tick delta sparkline
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
@@ -8,31 +18,150 @@ const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
const COL_GAP := 16
|
||||
|
||||
# World overlay colors
|
||||
const LOS_COLOR := Color(0.27, 0.78, 0.65, 0.50)
|
||||
const PLAYER_DOT_COLOR := Color(0.88, 0.77, 0.28, 0.85)
|
||||
const CONE_FORWARD_COLOR := Color(0.27, 0.78, 0.65, 0.12)
|
||||
const CONE_PERIPHERAL_COLOR := Color(0.20, 0.55, 0.80, 0.07)
|
||||
const CONE_RING_COLOR := Color(0.27, 0.78, 0.65, 0.55)
|
||||
const NPC_PATH_COLOR := Color(0.83, 0.48, 0.35, 0.75)
|
||||
const NPC_DOT_COLOR := Color(0.83, 0.48, 0.35, 0.90)
|
||||
const TAG_BG_COLOR := Color(0.05, 0.05, 0.10, 0.80)
|
||||
const TAG_TEXT_COLOR := Color("#c8d0e0")
|
||||
const GRAPH_BG_COLOR := Color(0.06, 0.06, 0.10, 0.82)
|
||||
const GRAPH_LINE_COLOR := Color("#6bc9a6")
|
||||
const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||
|
||||
# Vision cone geometry (radians)
|
||||
# Forward: ±60° around facing direction (120° total)
|
||||
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||
const CONE_ARC_STEPS: int = 20
|
||||
|
||||
# NPC path history
|
||||
const NPC_HISTORY_LEN: int = 12
|
||||
const NPC_DOT_RADIUS: float = 3.5
|
||||
const PLAYER_DOT_RADIUS: float = 5.0
|
||||
|
||||
# Tick timing graph
|
||||
const GRAPH_W: float = 160.0
|
||||
const GRAPH_H: float = 48.0
|
||||
const GRAPH_MARGIN: float = 10.0
|
||||
const TICK_HISTORY_LEN: int = 30
|
||||
const TICK_WARN_MS: float = 120.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _cached_font: Font = null
|
||||
var _dev_mode: bool = false
|
||||
|
||||
# NPC path history: entity_id (int) → Array of Vector2 (world positions)
|
||||
var _npc_paths: Dictionary = {}
|
||||
var _last_tick_processed: int = -1
|
||||
|
||||
# Tick timing ring
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_dev_mode = OS.is_debug_build()
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
# Clear NPC path history on session change to prevent entity ID collisions
|
||||
GameState.connect("game_id_changed", _on_game_id_changed)
|
||||
|
||||
|
||||
func _on_game_id_changed(_new_id: String) -> void:
|
||||
_npc_paths.clear()
|
||||
_tick_deltas.clear()
|
||||
_tick_times.clear()
|
||||
_last_tick_processed = -1
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _dev_mode:
|
||||
return
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
if not _dev_mode or not visible:
|
||||
return
|
||||
|
||||
# Record tick arrival time for timing graph
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if _last_tick_processed != GameState.current_tick:
|
||||
_last_tick_processed = GameState.current_tick
|
||||
if _tick_times.size() > 0:
|
||||
_tick_deltas.append(float(now_ms - _tick_times.back()))
|
||||
if _tick_deltas.size() > TICK_HISTORY_LEN:
|
||||
_tick_deltas.pop_front()
|
||||
_tick_times.append(now_ms)
|
||||
if _tick_times.size() > TICK_HISTORY_LEN + 1:
|
||||
_tick_times.pop_front()
|
||||
_update_npc_paths()
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _update_npc_paths() -> void:
|
||||
var seen_ids: Dictionary = {}
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
var kind_variant: String = entity.get("kind", {}).get("variant", "")
|
||||
if kind_variant != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if eid < 0:
|
||||
continue
|
||||
seen_ids[eid] = true
|
||||
var pos := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
if not _npc_paths.has(eid):
|
||||
_npc_paths[eid] = []
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() == 0 or path.back() != pos:
|
||||
path.append(pos)
|
||||
if path.size() > NPC_HISTORY_LEN:
|
||||
path.pop_front()
|
||||
# Prune entities no longer visible
|
||||
for eid in _npc_paths.keys():
|
||||
if not seen_ids.has(eid):
|
||||
_npc_paths.erase(eid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Draw dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
_draw_stats_panel()
|
||||
_draw_world_overlays()
|
||||
_draw_tick_graph()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 1: Stats text (top-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_stats_panel() -> void:
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
@@ -71,7 +200,10 @@ func _draw() -> void:
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var gid := GameState.current_game_id
|
||||
left_lines.append(["game_id", gid if gid != "" else "-"])
|
||||
right_lines.append(["npc_paths", str(_npc_paths.size())])
|
||||
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
@@ -89,27 +221,240 @@ func _draw() -> void:
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 2: World overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_world_overlays() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
# get_canvas_transform() applies Camera2D — valid for CanvasLayer 0 content.
|
||||
# The DebugOverlay is on UILayer (layer 20) so its own draw space IS screen space.
|
||||
# Using this transform converts world coords → screen pixel coords for the overlays.
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_screen := _w2s(GameState.player_position, canvas_xf)
|
||||
|
||||
_draw_vision_cone(player_screen, canvas_xf)
|
||||
_draw_los_rays(player_screen, canvas_xf)
|
||||
_draw_npc_paths(canvas_xf)
|
||||
_draw_info_tags(canvas_xf)
|
||||
|
||||
|
||||
# Convert world tile position → screen pixel position
|
||||
func _w2s(world_pos: Vector2, canvas_xf: Transform2D) -> Vector2:
|
||||
return canvas_xf * (world_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# Vision cone: filled forward sector + peripheral bands.
|
||||
# Uses player facing direction and visibility sector distance.
|
||||
func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
var facing_angle := _facing_to_angle(GameState.player_facing)
|
||||
|
||||
# Estimate visible radius from furthest visibility sector tile
|
||||
var max_d: float = 4.0
|
||||
for vpos in GameState.visibility_sectors.keys():
|
||||
var d := Vector2(vpos.x, vpos.y).distance_to(GameState.player_position)
|
||||
if d > max_d:
|
||||
max_d = d
|
||||
var scale_x := canvas_xf.x.length()
|
||||
var r: float = clampf(max_d * Constants.TILE_SIZE * scale_x, 40.0, 280.0)
|
||||
|
||||
# Helper: build a polygon fan from center outward over arc [angle_from, angle_to]
|
||||
var forward_from := facing_angle - CONE_FORWARD_HALF
|
||||
var forward_to := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_l_from := facing_angle - CONE_PERIPHERAL_HALF
|
||||
var perip_l_to := facing_angle - CONE_FORWARD_HALF
|
||||
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||
|
||||
# Forward arc boundary ring
|
||||
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||
|
||||
# Player dot
|
||||
draw_circle(player_screen, PLAYER_DOT_RADIUS, PLAYER_DOT_COLOR)
|
||||
|
||||
|
||||
# Build a filled polygon fan from center through an arc
|
||||
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
pts.append(center)
|
||||
for i in range(CONE_ARC_STEPS + 1):
|
||||
var t := float(i) / float(CONE_ARC_STEPS)
|
||||
var a := angle_from + t * (angle_to - angle_from)
|
||||
pts.append(center + Vector2(cos(a), sin(a)) * radius)
|
||||
return pts
|
||||
|
||||
|
||||
# Dashed LOS lines from player to each visible non-player entity
|
||||
func _draw_los_rays(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") == "Player":
|
||||
continue
|
||||
var entity_world := Vector2(entity.get("x", 0.0), entity.get("y", 0.0))
|
||||
var entity_screen := _w2s(entity_world, canvas_xf)
|
||||
var rel: String = entity.get("relationship", "Unknown")
|
||||
var color := Constants.color_for_relationship(rel)
|
||||
color.a = 0.45
|
||||
draw_dashed_line(player_screen, entity_screen, color, 1.0, 6.0)
|
||||
draw_circle(entity_screen, NPC_DOT_RADIUS, Color(color.r, color.g, color.b, 0.7))
|
||||
|
||||
|
||||
# Fading NPC movement path trails from position history
|
||||
func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
for eid in _npc_paths.keys():
|
||||
var path: Array = _npc_paths[eid]
|
||||
if path.size() < 2:
|
||||
continue
|
||||
for i in range(1, path.size()):
|
||||
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||
var b_screen := _w2s(path[i], canvas_xf)
|
||||
var alpha := float(i) / float(path.size())
|
||||
draw_line(a_screen, b_screen, Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||
|
||||
|
||||
# Information state tags above visible NPCs from player_knowledge
|
||||
func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||
if GameState.player_knowledge == null:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var knowledge: Dictionary = GameState.player_knowledge
|
||||
|
||||
# Build entity_id → knowledge entry lookup
|
||||
var kg_by_id: Dictionary = {}
|
||||
for entry in knowledge.get("entities", []):
|
||||
if entry is Dictionary and entry.has("entity_id"):
|
||||
kg_by_id[entry.entity_id] = entry
|
||||
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if entity.get("kind", {}).get("variant", "") != "Npc":
|
||||
continue
|
||||
var eid: int = entity.get("entity_id", -1)
|
||||
if not kg_by_id.has(eid):
|
||||
continue
|
||||
var kg_entry: Dictionary = kg_by_id[eid]
|
||||
var confidence: String = kg_entry.get("confidence", "Unknown")
|
||||
var name_str: String = kg_entry.get("name", "?")
|
||||
var label := "%s [%s]" % [name_str, confidence]
|
||||
|
||||
var entity_screen := _w2s(Vector2(entity.get("x", 0.0), entity.get("y", 0.0)), canvas_xf)
|
||||
var tag_baseline := entity_screen.y - 18.0
|
||||
var text_w := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1).x
|
||||
var tag_rect := Rect2(
|
||||
entity_screen.x - text_w / 2.0 - 3.0,
|
||||
tag_baseline - FONT_SIZE + 2.0,
|
||||
text_w + 6.0,
|
||||
FONT_SIZE)
|
||||
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3: Tick timing sparkline (bottom-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _draw_tick_graph() -> void:
|
||||
if _tick_deltas.size() < 2:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_x := GRAPH_MARGIN
|
||||
var label_h := LINE_HEIGHT
|
||||
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||
|
||||
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||
draw_string(font,
|
||||
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
var chart_top := box_y + label_h
|
||||
var chart_left := box_x + 4.0
|
||||
var chart_w := GRAPH_W - 8.0
|
||||
var chart_h := GRAPH_H - 4.0
|
||||
|
||||
# Max value for scale
|
||||
var max_ms: float = TICK_WARN_MS
|
||||
for d in _tick_deltas:
|
||||
if float(d) > max_ms:
|
||||
max_ms = float(d)
|
||||
max_ms *= 1.1
|
||||
|
||||
# Warn threshold dashed line
|
||||
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||
draw_dashed_line(
|
||||
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||
1.0, 4.0)
|
||||
|
||||
# Sparkline
|
||||
var n := _tick_deltas.size()
|
||||
var prev_pt := Vector2.ZERO
|
||||
for i in range(n):
|
||||
var x := chart_left + chart_w * (float(i) / float(n - 1))
|
||||
var clamped := clampf(float(_tick_deltas[i]), 0.0, max_ms)
|
||||
var y := chart_top + chart_h * (1.0 - clamped / max_ms)
|
||||
var pt := Vector2(x, y)
|
||||
var color := GRAPH_WARN_COLOR if float(_tick_deltas[i]) > TICK_WARN_MS else GRAPH_LINE_COLOR
|
||||
if i > 0:
|
||||
draw_line(prev_pt, pt, color, 1.5)
|
||||
draw_circle(pt, 2.0, color)
|
||||
prev_pt = pt
|
||||
|
||||
# Average label
|
||||
var avg_ms := 0.0
|
||||
for d in _tick_deltas:
|
||||
avg_ms += float(d)
|
||||
avg_ms /= float(_tick_deltas.size())
|
||||
draw_string(font,
|
||||
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||
"avg %.0fms" % avg_ms,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||
static func _facing_to_angle(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return -PI / 2.0
|
||||
"Northeast": return -PI / 4.0
|
||||
"East": return 0.0
|
||||
"Southeast": return PI / 4.0
|
||||
"South": return PI / 2.0
|
||||
"Southwest": return PI * 3.0 / 4.0
|
||||
"West": return PI
|
||||
"Northwest": return -PI * 3.0 / 4.0
|
||||
_: return -PI / 2.0
|
||||
|
||||
@@ -0,0 +1,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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env -S godot -s
|
||||
## gdUnit4 CI runner for the Settled Reach client.
|
||||
##
|
||||
## Run all client tests headlessly:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/
|
||||
##
|
||||
## Run a specific test file:
|
||||
## godot --headless --path client/ \
|
||||
## -s res://tests/run_gdunit4.gd \
|
||||
## -- --ignoreHeadlessMode -a res://tests/test_protocol.gd
|
||||
##
|
||||
## Exit code: 0 = all pass, non-zero = failures.
|
||||
##
|
||||
## D-030 (architecture.md): gdUnit4 is the confirmed Godot test framework.
|
||||
## Test output format: JSON summary per D-030 sub-decision #6.
|
||||
extends SceneTree
|
||||
|
||||
var _cli_runner: GdUnitTestCIRunner
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
|
||||
_cli_runner = GdUnitTestCIRunner.new()
|
||||
root.add_child(_cli_runner)
|
||||
|
||||
|
||||
func _finalize() -> void:
|
||||
queue_delete(_cli_runner)
|
||||
@@ -0,0 +1,317 @@
|
||||
## Sprint 19 — Debug visualization overlay (#348)
|
||||
## F3 toggle, world overlays, tick timing graph.
|
||||
## Extends the existing debug_overlay.gd stub.
|
||||
class_name TestDebugOverlaySprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||
## Does not require the full main.tscn scene tree.
|
||||
var script := load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||
return null
|
||||
var node := Control.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.current_tick = 1
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script existence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_that(ol).is_not_null()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_debug_overlay_starts_hidden() -> void:
|
||||
## Overlay starts hidden — only appears when F3 pressed.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.visible).override_failure_message(
|
||||
"DebugOverlay must start hidden (visible=false)"
|
||||
).is_false()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||
"DebugOverlay must have update_from_state() method"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||
## update_from_state() called while hidden must not crash.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = false
|
||||
ol.update_from_state() # Should be a no-op, no crash
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate a minimal snapshot tick
|
||||
GameState.current_tick = 42
|
||||
ol.update_from_state()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPC path tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_paths_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_updated_on_state_update() -> void:
|
||||
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2,
|
||||
"x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 100
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"_npc_paths must record NPC positions from visible_entities"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||
## Player entities must not appear in NPC path history.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
}]
|
||||
GameState.current_tick = 101
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"Player entity must not appear in _npc_paths"
|
||||
).is_equal(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_max_length_respected() -> void:
|
||||
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||
for i in range(20):
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5,
|
||||
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 200 + i
|
||||
ol.update_from_state()
|
||||
var path: Array = ol._npc_paths.get(5, [])
|
||||
assert_int(path.size()).override_failure_message(
|
||||
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick timing ring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_tick_deltas_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(5):
|
||||
GameState.current_tick = 300 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must accumulate entries from successive ticks"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_max_length_respected() -> void:
|
||||
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(50):
|
||||
GameState.current_tick = 400 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants defined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_warn_ms_constant_defined() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing angle helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_facing_to_angle_north() -> void:
|
||||
## North = -PI/2 in Godot 2D (up on screen)
|
||||
var angle := _fetch_facing_angle("North")
|
||||
assert_float(angle).override_failure_message(
|
||||
"_facing_to_angle('North') must return -PI/2"
|
||||
).is_equal_approx(-PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_east() -> void:
|
||||
var angle := _fetch_facing_angle("East")
|
||||
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_south() -> void:
|
||||
var angle := _fetch_facing_angle("South")
|
||||
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_west() -> void:
|
||||
var angle := _fetch_facing_angle("West")
|
||||
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||
|
||||
|
||||
func _fetch_facing_angle(facing: String) -> float:
|
||||
## Helper: load script and call static method.
|
||||
var script = load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
return 0.0
|
||||
# In GDScript 4, static methods can be called via an instance
|
||||
var tmp := Control.new()
|
||||
tmp.set_script(script)
|
||||
add_child(tmp)
|
||||
var result := tmp._facing_to_angle(facing)
|
||||
tmp.queue_free()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-scene placement: DebugOverlay on UILayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var ui_layer := scene.get_node_or_null("UILayer")
|
||||
assert_that(ui_layer != null).override_failure_message(
|
||||
"UILayer must exist in main.tscn"
|
||||
).is_true()
|
||||
if ui_layer == null: return
|
||||
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||
assert_that(overlay != null).override_failure_message(
|
||||
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||
).is_true()
|
||||
@@ -596,3 +596,77 @@ func test_is_dialogue_active_true_after_show_dialogue() -> void:
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(box.is_dialogue_active()).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
|
||||
# directly mutating GameState or calling AudioManager.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_dialogue_state_changed_emits_true_on_show() -> void:
|
||||
## D-020: show_dialogue() must emit dialogue_state_changed(true).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(received.has(true)).override_failure_message(
|
||||
"dialogue_state_changed(true) must be emitted on show_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_dialogue_state_changed_emits_false_on_hide() -> void:
|
||||
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
box.hide_dialogue()
|
||||
assert_bool(received.has(false)).override_failure_message(
|
||||
"dialogue_state_changed(false) must be emitted on hide_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
|
||||
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.audio_dip_requested.connect(func(profile): received.append(profile))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(received.has("dialogue")).override_failure_message(
|
||||
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_audio_dip_cleared_emits_on_hide() -> void:
|
||||
## D-020: hide_dialogue() must emit audio_dip_cleared.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var cleared := [false]
|
||||
box.audio_dip_cleared.connect(func(): cleared[0] = true)
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
box.hide_dialogue()
|
||||
assert_bool(cleared[0]).override_failure_message(
|
||||
"audio_dip_cleared must be emitted on hide_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_no_direct_game_state_mutation() -> void:
|
||||
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
|
||||
## After show_dialogue, GameState.dialogue_active should remain unchanged
|
||||
## (only the coordinator updates it via signal handler).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
GameState.dialogue_active = false
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
GameState.dialogue_active = false
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
## Sprint 20 #558: dialogue_box.gd decoupling tests.
|
||||
##
|
||||
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
|
||||
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestDialogueSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSprint20: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# -- dialogue_state_changed signal -------------------------------------------
|
||||
|
||||
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
|
||||
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var received: Variant = null
|
||||
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_that(received).override_failure_message(
|
||||
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
|
||||
).is_equal(true)
|
||||
|
||||
|
||||
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
|
||||
## Without a connected handler, GameState.dialogue_active must stay false.
|
||||
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
GameState.dialogue_active = false
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
|
||||
## hide_dialogue() must emit dialogue_state_changed(false).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var received: Variant = null
|
||||
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||
box.hide_dialogue()
|
||||
assert_that(received).override_failure_message(
|
||||
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
|
||||
).is_equal(false)
|
||||
|
||||
|
||||
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
|
||||
|
||||
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
|
||||
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var received_profile: Variant = null
|
||||
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_that(received_profile).override_failure_message(
|
||||
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
|
||||
).is_equal("dialogue")
|
||||
|
||||
|
||||
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
|
||||
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
|
||||
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var dip_before := AudioManager.get_active_dip()
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var dip_after := AudioManager.get_active_dip()
|
||||
assert_str(dip_after).override_failure_message(
|
||||
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
|
||||
).is_equal(dip_before)
|
||||
|
||||
|
||||
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
|
||||
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var cleared := false
|
||||
box.audio_dip_cleared.connect(func(): cleared = true)
|
||||
box.hide_dialogue()
|
||||
assert_bool(cleared).override_failure_message(
|
||||
"hide_dialogue() must emit audio_dip_cleared (#558)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_audio_dip_cleared_count_on_conversation_end() -> void:
|
||||
## Verify audio_dip_cleared fires when conversation ends.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var cleared_count := 0
|
||||
box.audio_dip_cleared.connect(func(): cleared_count += 1)
|
||||
box.show_dialogue("NPC", "Speak.", [])
|
||||
box.hide_dialogue()
|
||||
assert_int(cleared_count).override_failure_message(
|
||||
"audio_dip_cleared must fire at least once when conversation ends (#558)"
|
||||
).is_greater_equal(1)
|
||||
|
||||
|
||||
# -- coordinator wiring verification -----------------------------------------
|
||||
|
||||
func test_signal_handler_wires_game_state() -> void:
|
||||
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
|
||||
## Verifies the coordinator pattern works end-to-end (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
|
||||
).is_true()
|
||||
box.hide_dialogue()
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
|
||||
).is_false()
|
||||
@@ -0,0 +1,334 @@
|
||||
## Sprint 18 — Examine result display (#174)
|
||||
## Spec refs: D-061 (adjacent to dialogue spec), D-041 (character-filtered observation)
|
||||
##
|
||||
## ExamineDisplay: non-interactive overlay, diegetic, auto-dismisses after DISMISS_DELAY.
|
||||
## Positioned in InsertOverlay (CanvasLayer 10).
|
||||
## GameState.current_examine_result: cleared every snapshot (unlike player_knowledge).
|
||||
##
|
||||
## Tests run against live Stig implementation (examine_display.gd).
|
||||
class_name TestExamineDisplaySprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const EXAMINE_SCENE_PATH: String = "res://ui/examine_display.tscn"
|
||||
|
||||
func _make_examine_display() -> Control:
|
||||
if not ResourceLoader.exists(EXAMINE_SCENE_PATH):
|
||||
push_warning("TestExamineDisplaySprint18: examine_display.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(EXAMINE_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_result(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||
"confidence": "KnowsOf",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: current_examine_result parsing
|
||||
## (Now tests real implementation — not test-first stubs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (#174)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_by_default() -> void:
|
||||
GameState.current_examine_result = null
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({"tick": 5, "examine_result": _make_result()})
|
||||
assert_that(GameState.current_examine_result).is_not_null()
|
||||
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||
## CONTRAST with player_knowledge: examine_result DOES clear each snapshot.
|
||||
## The overlay must auto-dismiss — the server never re-sends the same result.
|
||||
GameState.current_examine_result = _make_result()
|
||||
GameState.apply_snapshot({"tick": 6})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||
assert_that(GameState.current_examine_result).is_null()
|
||||
|
||||
|
||||
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"entity_id": 99})})
|
||||
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||
|
||||
|
||||
func test_gamestate_examine_result_confidence_survives_roundtrip() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"confidence": "Direct"})})
|
||||
assert_that(GameState.current_examine_result.get("confidence")).is_equal("Direct")
|
||||
|
||||
|
||||
func test_gamestate_examine_result_replaced_on_next_snapshot() -> void:
|
||||
## Two examine results in sequence — second replaces first.
|
||||
GameState.apply_snapshot({"tick": 1, "examine_result": _make_result({"text": "First observation."})})
|
||||
GameState.apply_snapshot({"tick": 2, "examine_result": _make_result({"text": "Second observation."})})
|
||||
assert_that(GameState.current_examine_result.get("text")).is_equal("Second observation.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(EXAMINE_SCENE_PATH)).override_failure_message(
|
||||
"ExamineDisplay scene must exist at res://ui/examine_display.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_examine_display_instantiates_without_crash() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_that(display).is_not_null()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_show_result_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("show_result")).override_failure_message(
|
||||
"ExamineDisplay must have show_result(result: Dictionary) method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_dismiss_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("dismiss")).override_failure_message(
|
||||
"ExamineDisplay must have dismiss() method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_has_is_active_method() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.has_method("is_active")).override_failure_message(
|
||||
"ExamineDisplay must have is_active() method"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_not_active_on_init() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"ExamineDisplay must start inactive (no result showing)"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_not_visible_on_init() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_bool(display.visible).override_failure_message(
|
||||
"ExamineDisplay must start invisible"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_active_after_show_result() -> void:
|
||||
## show_result() with valid text sets is_active() = true.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() with text must set is_active() = true"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_visible_after_show_result() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.visible).override_failure_message(
|
||||
"show_result() must set visible = true"
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_empty_text_ignored() -> void:
|
||||
## show_result() with empty text must not activate (D-041: no empty observations).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result({"entity_id": 1, "text": "", "confidence": "KnowsOf"})
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() with empty text must not activate the display"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_inactive_after_dismiss() -> void:
|
||||
## dismiss() immediately starts fade-out and sets _active = false.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result())
|
||||
assert_bool(display.is_active()).is_true()
|
||||
display.dismiss()
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"dismiss() must set is_active() = false immediately"
|
||||
).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_dismiss_when_inactive_no_crash() -> void:
|
||||
## dismiss() on an inactive display must be safe (no crash, no state corruption).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.dismiss() # called when not active
|
||||
assert_bool(display.is_active()).is_false()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_examine_display_show_replaces_previous() -> void:
|
||||
## Second show_result() replaces first (only one result at a time).
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
display.show_result(_make_result({"text": "First observation."}))
|
||||
display.show_result(_make_result({"text": "Second observation."}))
|
||||
assert_bool(display.is_active()).override_failure_message(
|
||||
"show_result() called twice must leave display active"
|
||||
).is_true()
|
||||
## Text label should reflect the second result
|
||||
var text_label := display.get_node_or_null("PanelContainer/MarginContainer/TextLabel")
|
||||
if text_label is RichTextLabel:
|
||||
assert_that(text_label.text).override_failure_message(
|
||||
"Second show_result() must replace the displayed text"
|
||||
).is_equal("Second observation.")
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay: DISMISS_DELAY within spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_dismiss_delay_within_spec() -> void:
|
||||
## Spec (sprint-18/client.md): auto-dismisses after 4–6 seconds.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.DISMISS_DELAY).override_failure_message(
|
||||
"DISMISS_DELAY must be 4–6 seconds per spec"
|
||||
).is_between(4.0, 6.0)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExamineDisplay: CONFIDENCE_ALPHA — confidence-based alpha modulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_confidence_alpha_dict_covers_all_levels() -> void:
|
||||
## All four confidence levels must have alpha mappings.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
for level in ["Direct", "KnowsDetails", "KnowsOf", "Suspects"]:
|
||||
assert_bool(alpha_dict.has(level)).override_failure_message(
|
||||
"CONFIDENCE_ALPHA must map '%s'" % level
|
||||
).is_true()
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_direct_is_highest() -> void:
|
||||
## Direct confidence = brightest (alpha 1.0). Character fully trusts this observation.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
assert_float(alpha_dict.get("Direct", 0.0)).override_failure_message(
|
||||
"Direct confidence must have alpha 1.0 (brightest)"
|
||||
).is_equal_approx(1.0, 0.001)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_suspects_is_lowest() -> void:
|
||||
## Suspects = most dimmed (lowest alpha). Uncertainty is visually represented.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
var alpha_dict: Dictionary = display.CONFIDENCE_ALPHA
|
||||
var suspects_alpha: float = alpha_dict.get("Suspects", 1.0)
|
||||
var direct_alpha: float = alpha_dict.get("Direct", 0.0)
|
||||
assert_float(suspects_alpha).override_failure_message(
|
||||
"Suspects alpha must be less than Direct alpha (dimmer = less certain)"
|
||||
).is_less(direct_alpha)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_confidence_alpha_all_values_valid() -> void:
|
||||
## All alpha values must be in [0.0, 1.0].
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
for key in display.CONFIDENCE_ALPHA:
|
||||
var alpha: float = display.CONFIDENCE_ALPHA[key]
|
||||
assert_float(alpha).override_failure_message(
|
||||
"CONFIDENCE_ALPHA['%s'] = %.2f must be in [0, 1]" % [key, alpha]
|
||||
).is_between(0.0, 1.0)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-interactive: mouse_filter must be IGNORE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_examine_display_mouse_filter_ignore() -> void:
|
||||
## ExamineDisplay is non-interactive — must not consume mouse events.
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_int(display.mouse_filter).override_failure_message(
|
||||
"ExamineDisplay must have mouse_filter=IGNORE (non-interactive overlay)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fade constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_fade_in_is_short() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.FADE_IN).is_between(0.0, 0.5)
|
||||
display.queue_free()
|
||||
|
||||
|
||||
func test_fade_out_is_short() -> void:
|
||||
var display := _make_examine_display()
|
||||
if display == null: return
|
||||
assert_float(display.FADE_OUT).is_between(0.0, 0.5)
|
||||
display.queue_free()
|
||||
@@ -0,0 +1,215 @@
|
||||
## GameState.apply_snapshot() tests — v2+ field coverage.
|
||||
##
|
||||
## Complements test_snapshot_parsing.gd (which covers v1 basics: tick, entities,
|
||||
## player_position). This file covers v2+ fields and derived state.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestGameState
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset fields touched by these tests to known defaults.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.game_time = {}
|
||||
GameState.nearby_interactions = []
|
||||
GameState.current_monologue = null
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# -- v2: game_time (D-031) -------------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_game_time() -> void:
|
||||
var snapshot := {
|
||||
"tick": 10,
|
||||
"entities": [],
|
||||
"game_time": {"day": 3, "time_of_day": 480, "day_phase": "Morning", "tick_rate": 1},
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.game_time).is_not_null()
|
||||
assert_that(GameState.game_time.get("day")).is_equal(3)
|
||||
assert_that(GameState.game_time.get("time_of_day")).is_equal(480)
|
||||
|
||||
|
||||
func test_apply_snapshot_game_time_missing_keeps_previous() -> void:
|
||||
GameState.game_time = {"day": 2, "time_of_day": 360}
|
||||
GameState.apply_snapshot({"tick": 5, "entities": []})
|
||||
# No "game_time" key → field unchanged
|
||||
assert_that(GameState.game_time.get("day")).is_equal(2)
|
||||
|
||||
|
||||
# -- v2: player_facing (D-015) --------------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_facing() -> void:
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [],
|
||||
"player_facing": "Southeast",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.player_facing).is_equal("Southeast")
|
||||
|
||||
|
||||
func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||
GameState.player_facing = "West"
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
assert_that(GameState.player_facing).is_equal("West")
|
||||
|
||||
|
||||
# -- v4: nearby_interactions (#404/#405) ----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||
var interactions := [
|
||||
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
||||
assert_that(GameState.nearby_interactions[0].get("entity_id")).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||
GameState.nearby_interactions = [{"entity_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- v5: current_monologue (#414) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_monologue() -> void:
|
||||
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||
|
||||
|
||||
func test_apply_snapshot_monologue_absent_clears_field() -> void:
|
||||
GameState.current_monologue = {"id": "old", "text": "Old line."}
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
# -- v6: player_stance (#449, D-053) --------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_stance() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Crouch"})
|
||||
assert_that(GameState.player_stance).is_equal("Crouch")
|
||||
|
||||
|
||||
# -- v6: player_inventory (#449, D-065) -----------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_player_inventory() -> void:
|
||||
var inventory := [{"item_id": 42, "name": "Security pass", "slot": 0}]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inventory})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(1)
|
||||
assert_that(GameState.player_inventory[0].get("name")).is_equal("Security pass")
|
||||
|
||||
|
||||
func test_apply_snapshot_inventory_absent_clears_list() -> void:
|
||||
GameState.player_inventory = [{"item_id": 1}]
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_that(GameState.player_inventory.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Stationary ticks: server-authoritative (D-020/D-071) -----------------
|
||||
|
||||
func test_stationary_ticks_from_server_snapshot() -> void:
|
||||
## D-020: When server sends stationary_ticks, client reads it directly.
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "stationary_ticks": 42})
|
||||
assert_int(GameState.stationary_ticks).is_equal(42)
|
||||
|
||||
|
||||
func test_stationary_ticks_server_value_overrides_client_accumulation() -> void:
|
||||
## D-020: Server value takes priority — client must not accumulate on top of it.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
"stationary_ticks": 5,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
GameState.apply_snapshot(snapshot) # same position, but server sends 5 again
|
||||
assert_int(GameState.stationary_ticks).is_equal(5) # server value, not 6
|
||||
|
||||
|
||||
func test_stationary_ticks_missing_field_degrades_gracefully() -> void:
|
||||
## D-020 fallback: when server omits stationary_ticks, no crash, defaults to 0.
|
||||
GameState.stationary_ticks = 0
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
# No crash; field retains a valid integer value.
|
||||
assert_int(GameState.stationary_ticks).is_greater_equal(0)
|
||||
|
||||
|
||||
# -- Stationary ticks: deprecated client-side fallback (D-071) -----------
|
||||
|
||||
func test_stationary_ticks_fallback_increments_when_position_unchanged() -> void:
|
||||
## Deprecated fallback: client accumulates when server omits the field.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot) # first call: position changes from sentinel
|
||||
GameState.apply_snapshot(snapshot) # second call: position unchanged → +1
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
|
||||
|
||||
func test_stationary_ticks_fallback_resets_on_movement() -> void:
|
||||
## Deprecated fallback: client resets on movement when server omits the field.
|
||||
var s1 := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
var s2 := {
|
||||
"tick": 2,
|
||||
"entities": [{"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
}
|
||||
GameState.apply_snapshot(s1)
|
||||
GameState.apply_snapshot(s1) # stationary
|
||||
assert_int(GameState.stationary_ticks).is_greater(0)
|
||||
GameState.apply_snapshot(s2) # moved → reset
|
||||
assert_int(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
|
||||
# -- Zone ID: server-authoritative (D-020/D-073) -------------------------
|
||||
|
||||
func test_zone_id_from_server_snapshot() -> void:
|
||||
## D-020: When server sends top-level zone_id, client reads it directly.
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "zone_id": "zone_alpha"})
|
||||
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||
|
||||
|
||||
func test_zone_id_server_value_overrides_tile_derivation() -> void:
|
||||
## D-020: Server top-level zone_id takes priority over tile-derived zone_id.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}}],
|
||||
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"}],
|
||||
"zone_id": "zone_from_server",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.current_zone_id).is_equal("zone_from_server")
|
||||
|
||||
|
||||
func test_zone_id_missing_field_degrades_gracefully() -> void:
|
||||
## D-020 fallback: when server omits zone_id and no tiles match, defaults to "".
|
||||
GameState.current_zone_id = ""
|
||||
GameState.apply_snapshot({"tick": 1, "entities": []})
|
||||
assert_that(GameState.current_zone_id).is_equal("")
|
||||
|
||||
|
||||
# -- insert_active (OQ-07, #522) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_insert_active_false() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "insert_active": false})
|
||||
assert_bool(GameState.insert_active).is_false()
|
||||
|
||||
|
||||
func test_apply_snapshot_insert_active_defaults_true_when_absent() -> void:
|
||||
GameState.insert_active = false
|
||||
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||
assert_bool(GameState.insert_active).is_true()
|
||||
@@ -0,0 +1,164 @@
|
||||
## Sprint 20 #557: GameState.apply_snapshot() refactor tests.
|
||||
##
|
||||
## Verifies D-020 compliance: apply_snapshot() reads server-authoritative values
|
||||
## for stationary_ticks and zone_id directly from the snapshot when present,
|
||||
## and degrades gracefully when the server has not yet added these fields.
|
||||
##
|
||||
## Does NOT replace test_game_state.gd or test_snapshot_zone_id.gd — those cover
|
||||
## existing behaviour. This file covers the new code paths added in Sprint 20.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestGameStateSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.visibility_sectors = {}
|
||||
|
||||
|
||||
# -- stationary_ticks: server-authoritative path (D-020) ----------------------
|
||||
|
||||
func test_stationary_ticks_reads_server_value_when_present() -> void:
|
||||
# When snapshot includes stationary_ticks, apply_snapshot() must use the
|
||||
# server value directly without client-side accumulation (D-020).
|
||||
var snapshot := {
|
||||
"tick": 5,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"stationary_ticks": 42,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"apply_snapshot() must read stationary_ticks=42 from snapshot (D-020)"
|
||||
).is_equal(42)
|
||||
|
||||
|
||||
func test_stationary_ticks_server_value_does_not_accumulate() -> void:
|
||||
# Server-sent value must be assigned directly — NOT added to existing value.
|
||||
# Two calls with stationary_ticks=10 must yield 10, not 20.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"stationary_ticks": 10,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"Server value must be assigned directly, not accumulated (D-020)"
|
||||
).is_equal(10)
|
||||
|
||||
|
||||
func test_stationary_ticks_server_can_reset_to_zero() -> void:
|
||||
# Server sends 0 when player moves — client must accept this reset.
|
||||
GameState.stationary_ticks = 50
|
||||
var snapshot := {
|
||||
"tick": 2,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"stationary_ticks": 0,
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"Server reset to 0 must override client-held value"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
# -- stationary_ticks: DEPRECATED fallback (graceful degradation) --------------
|
||||
|
||||
func test_stationary_ticks_fallback_when_field_absent() -> void:
|
||||
# When snapshot has no stationary_ticks, client-side accumulation must still
|
||||
# run (backward compat until server ships field). No crash.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot) # position changes from ZERO → resets to 0
|
||||
GameState.apply_snapshot(snapshot) # position unchanged → increments
|
||||
assert_int(GameState.stationary_ticks).override_failure_message(
|
||||
"Client-side fallback must increment stationary_ticks when field absent"
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
func test_apply_snapshot_missing_stationary_ticks_no_crash() -> void:
|
||||
# Snapshots lacking stationary_ticks must not crash apply_snapshot().
|
||||
var snapshot := {"tick": 1, "entities": []}
|
||||
# No assertion needed beyond confirming no exception is raised.
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_bool(true).is_true()
|
||||
|
||||
|
||||
# -- zone_id: server-authoritative path (D-020/D-073) -------------------------
|
||||
|
||||
func test_current_zone_id_reads_server_value_when_present() -> void:
|
||||
# When snapshot includes top-level zone_id, apply_snapshot() must use it
|
||||
# directly without tile lookup (D-020).
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"zone_id": "zone_server_direct",
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).override_failure_message(
|
||||
"apply_snapshot() must read zone_id='zone_server_direct' from snapshot (D-020)"
|
||||
).is_equal("zone_server_direct")
|
||||
|
||||
|
||||
func test_current_zone_id_server_value_overrides_tile_data() -> void:
|
||||
# When snapshot has both zone_id and visible_tiles with a different zone,
|
||||
# the top-level zone_id field takes priority.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"zone_id": "zone_from_server",
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_from_tile"},
|
||||
],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).override_failure_message(
|
||||
"Top-level zone_id must override tile-derived zone when both present"
|
||||
).is_equal("zone_from_server")
|
||||
|
||||
|
||||
# -- zone_id: DEPRECATED fallback (graceful degradation) ----------------------
|
||||
|
||||
func test_current_zone_id_fallback_to_tile_lookup_when_absent() -> void:
|
||||
# When snapshot lacks top-level zone_id, tile lookup fallback must run.
|
||||
# Existing test_snapshot_zone_id.gd covers detailed scenarios; this is a
|
||||
# smoke test confirming the fallback path still works after Sprint 20 refactor.
|
||||
var snapshot := {
|
||||
"tick": 1,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_tile_fallback"},
|
||||
],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).override_failure_message(
|
||||
"Tile lookup fallback must populate zone_id when top-level field absent"
|
||||
).is_equal("zone_tile_fallback")
|
||||
|
||||
|
||||
func test_apply_snapshot_missing_zone_id_no_crash() -> void:
|
||||
# Snapshots lacking both zone_id and visible_tiles must not crash.
|
||||
var snapshot := {"tick": 1, "entities": []}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_str(GameState.current_zone_id).is_equal("")
|
||||
@@ -0,0 +1,320 @@
|
||||
## Sprint 18 — Minimap rendering (#151)
|
||||
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
|
||||
## D-049 (z-layer 6 = InsertOverlay)
|
||||
##
|
||||
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
|
||||
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
|
||||
## Positioned at InsertOverlay/Minimap in main.tscn.
|
||||
##
|
||||
## Tests run against live Stig implementation (minimap.gd).
|
||||
class_name TestMinimapSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"id": "poi_test_001",
|
||||
"x": 20,
|
||||
"y": 15,
|
||||
"poi_category": "location",
|
||||
"label": "Exit A",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.insert_active = true
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene and class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
|
||||
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_minimap_instantiates_without_crash() -> void:
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_that(mm).is_not_null()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_minimap_is_minimap_renderer_class() -> void:
|
||||
## class_name MinimapRenderer in minimap.gd.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_bool(mm is MinimapRenderer).override_failure_message(
|
||||
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants: D-015, visual parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_radius_constant() -> void:
|
||||
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
|
||||
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
|
||||
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
|
||||
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
|
||||
).is_equal_approx(24.0, 0.01)
|
||||
|
||||
|
||||
func test_player_dot_radius_defined() -> void:
|
||||
## Player dot must be visible (> 0) and distinct from POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_poi_dot_radius_defined() -> void:
|
||||
## POI dot must be visible (> 0).
|
||||
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_player_dot_larger_than_poi_dot() -> void:
|
||||
## D-015: Player is always centered and visually distinct.
|
||||
## Player dot should be at least as large as POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _category_color() — D-013 POI category color mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_color_danger_is_hostile_color() -> void:
|
||||
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
|
||||
for cat in ["danger", "threat", "hostile"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
|
||||
func test_category_color_evidence_is_poi_color() -> void:
|
||||
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
|
||||
for cat in ["evidence", "note", "clue"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
|
||||
func test_category_color_contact_is_unknown_color() -> void:
|
||||
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
|
||||
for cat in ["contact", "npc", "person"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
|
||||
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
|
||||
var color: Color = MinimapRenderer._category_color("some_unknown_type")
|
||||
assert_that(color).override_failure_message(
|
||||
"Unknown category must default to INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_empty_string_defaults() -> void:
|
||||
## Empty category string → default color, no crash.
|
||||
var color: Color = MinimapRenderer._category_color("")
|
||||
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_case_insensitive() -> void:
|
||||
## Category matching is case-insensitive (uses to_lower()).
|
||||
var danger_lower := MinimapRenderer._category_color("danger")
|
||||
var danger_upper := MinimapRenderer._category_color("DANGER")
|
||||
var danger_mixed := MinimapRenderer._category_color("Danger")
|
||||
assert_that(danger_lower).is_equal(danger_upper)
|
||||
assert_that(danger_lower).is_equal(danger_mixed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_insert_active() — D-049: insert layer visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_set_insert_active_false_hides_minimap() -> void:
|
||||
## When insert is inactive, minimap must be hidden.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(false) must hide the minimap"
|
||||
).is_false()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_set_insert_active_true_shows_minimap() -> void:
|
||||
## When insert is active, minimap must be visible.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
mm.set_insert_active(true)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(true) must show the minimap"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scene structural check: InsertOverlay/Minimap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
|
||||
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
# Check for Minimap in InsertOverlay
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay")
|
||||
assert_that(insert_overlay != null).override_failure_message(
|
||||
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
|
||||
).is_true()
|
||||
if insert_overlay == null: return
|
||||
|
||||
var minimap := insert_overlay.get_node_or_null("Minimap")
|
||||
assert_that(minimap != null).override_failure_message(
|
||||
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
|
||||
).is_true()
|
||||
if minimap == null: return
|
||||
|
||||
assert_bool(minimap is MinimapRenderer).override_failure_message(
|
||||
"InsertOverlay/Minimap must be a MinimapRenderer instance"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
|
||||
if insert_overlay == null: return
|
||||
assert_int(insert_overlay.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.discovered_pois integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_discovered_pois_field_exists_in_gamestate() -> void:
|
||||
assert_bool(GameState.has("discovered_pois")).override_failure_message(
|
||||
"GameState must have 'discovered_pois' field (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
|
||||
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [
|
||||
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
|
||||
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
|
||||
],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must be populated from snapshot 'poi_list' field"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
|
||||
## Snapshot with "discovered_pois" key also works.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"discovered_pois": [_make_poi()],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
|
||||
## Like player_knowledge: POI list persists when server doesn't send an update.
|
||||
GameState.discovered_pois = [_make_poi()]
|
||||
GameState.apply_snapshot({"tick": 3})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_poi_category_field_present() -> void:
|
||||
## MinimapRenderer reads poi_category to determine shape/color.
|
||||
## Verify the wire format includes this field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"poi_category": "danger"})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("poi_category")).override_failure_message(
|
||||
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_x_y_fields_present() -> void:
|
||||
## MinimapRenderer reads x, y for position calculation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"x": 42, "y": 17})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
|
||||
"POI entries must have 'x' and 'y' coordinate fields"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color constants: all distinct
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_colors_are_distinct() -> void:
|
||||
## All three primary category color groups must be visually distinct.
|
||||
var danger_color := MinimapRenderer._category_color("danger")
|
||||
var evidence_color := MinimapRenderer._category_color("evidence")
|
||||
var contact_color := MinimapRenderer._category_color("contact")
|
||||
assert_that(danger_color).is_not_equal(evidence_color)
|
||||
assert_that(evidence_color).is_not_equal(contact_color)
|
||||
assert_that(danger_color).is_not_equal(contact_color)
|
||||
@@ -0,0 +1,208 @@
|
||||
## Sprint 19 — Game session management (#258, D-085)
|
||||
## Per-game save directories: created on New Game, resumed via game-id.
|
||||
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
_created_ids = []
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
for game_id in _created_ids:
|
||||
var path := "user://saves/" + game_id
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
_created_ids.clear()
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: call new_game() and track the created directory for cleanup.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _new_game() -> String:
|
||||
var game_id := SessionManager.new_game()
|
||||
if not game_id.is_empty():
|
||||
_created_ids.append(game_id)
|
||||
return game_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.current_game_id field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_current_game_id_field_exists() -> void:
|
||||
## D-085: GameState must have current_game_id field.
|
||||
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_current_game_id_default_is_empty_string() -> void:
|
||||
## Before any session starts, current_game_id is empty.
|
||||
GameState.current_game_id = ""
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"GameState.current_game_id default must be empty string"
|
||||
).is_empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionManager autoload exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_session_manager_autoload_exists() -> void:
|
||||
## SessionManager must be registered as an autoload.
|
||||
var sm := Engine.get_singleton("SessionManager")
|
||||
assert_that(sm != null).override_failure_message(
|
||||
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_game() — game-id format and GameState update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_game_returns_non_empty_string() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(game_id).override_failure_message(
|
||||
"SessionManager.new_game() must return a non-empty game-id string"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"new_game() must set GameState.current_game_id"
|
||||
).is_equal(game_id)
|
||||
|
||||
|
||||
func test_new_game_id_format_has_two_dashes() -> void:
|
||||
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts.size()).override_failure_message(
|
||||
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||
## First part is YYYYMMDD — 8 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[0].length()).override_failure_message(
|
||||
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||
).is_equal(8)
|
||||
|
||||
|
||||
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||
## Second part is HHMMSS — 6 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[1].length()).override_failure_message(
|
||||
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||
## Third part is 6 hex characters (RNG seed).
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[2].length()).override_failure_message(
|
||||
"game-id third part (hex seed) must be 6 characters"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_ids_are_unique() -> void:
|
||||
## Two rapid new_game() calls should produce different IDs
|
||||
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||
var id1 := _new_game()
|
||||
var id2 := _new_game()
|
||||
# Check that hex seeds differ (they almost certainly will)
|
||||
var seed1 := id1.split("-")[2]
|
||||
var seed2 := id2.split("-")[2]
|
||||
assert_str(seed1).override_failure_message(
|
||||
"Successive new_game() calls should have different RNG seeds"
|
||||
).is_not_equal(seed2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_game() — sets GameState.current_game_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_resume_game_sets_current_game_id() -> void:
|
||||
var test_id := "20260225-143022-a7b3f1"
|
||||
SessionManager.resume_game(test_id)
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"resume_game() must set GameState.current_game_id to the given id"
|
||||
).is_equal(test_id)
|
||||
|
||||
|
||||
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||
SessionManager.resume_game("20260225-120000-112233")
|
||||
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_main_menu_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
|
||||
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/NewGameBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/ContinueBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project main scene changed to main_menu.tscn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_project_main_scene_is_main_menu() -> void:
|
||||
## D-085: project boots to main menu, not directly to game scene.
|
||||
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
assert_str(scene_path).override_failure_message(
|
||||
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||
).is_equal("res://scenes/main_menu.tscn")
|
||||
@@ -0,0 +1,89 @@
|
||||
## SnapshotEventRouter unit tests (#559).
|
||||
## Verifies callable-based dispatch: keyed handlers receive correct field values,
|
||||
## always handlers run on every dispatch, absent fields don't trigger handlers.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestSnapshotEventRouter
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Keyed handlers -----------------------------------------------------------
|
||||
|
||||
func test_keyed_handler_called_when_field_present() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
var received: Array = []
|
||||
router.register("current_monologue", func(): received.append("monologue"))
|
||||
router.dispatch({"current_monologue": {"text": "Test."}})
|
||||
assert_that(received.size()).is_equal(1)
|
||||
assert_that(received[0]).is_equal("monologue")
|
||||
|
||||
|
||||
func test_keyed_handler_not_called_when_field_absent() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
var received: Array = []
|
||||
router.register("current_monologue", func(): received.append("monologue"))
|
||||
router.dispatch({"tick": 1})
|
||||
assert_that(received.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_multiple_keyed_handlers_same_field() -> void:
|
||||
## Multiple handlers on the same key run in registration order.
|
||||
var router := SnapshotEventRouter.new()
|
||||
var order: Array = []
|
||||
router.register("save_result", func(): order.append("first"))
|
||||
router.register("save_result", func(): order.append("second"))
|
||||
router.dispatch({"save_result": {"success": true}})
|
||||
assert_that(order).is_equal(["first", "second"])
|
||||
|
||||
|
||||
func test_keyed_handlers_multiple_fields() -> void:
|
||||
## Each keyed handler fires only for its registered field.
|
||||
var router := SnapshotEventRouter.new()
|
||||
var received: Array = []
|
||||
router.register("current_monologue", func(): received.append("mono"))
|
||||
router.register("current_dialogue", func(): received.append("dlg"))
|
||||
router.dispatch({"current_monologue": {"text": "Hi."}})
|
||||
assert_that(received).is_equal(["mono"])
|
||||
received.clear()
|
||||
router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}})
|
||||
assert_bool(received.has("mono")).is_true()
|
||||
assert_bool(received.has("dlg")).is_true()
|
||||
|
||||
|
||||
# -- Always handlers ----------------------------------------------------------
|
||||
|
||||
func test_always_handler_called_on_every_dispatch() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
var count: Array[int] = [0]
|
||||
router.register_always(func(): count[0] += 1)
|
||||
router.dispatch({"tick": 1})
|
||||
router.dispatch({"tick": 2})
|
||||
router.dispatch({})
|
||||
assert_int(count[0]).is_equal(3)
|
||||
|
||||
|
||||
func test_always_handler_runs_before_keyed() -> void:
|
||||
## Always handlers run before keyed handlers (dispatch order guarantee).
|
||||
var router := SnapshotEventRouter.new()
|
||||
var order: Array = []
|
||||
router.register("current_monologue", func(): order.append("keyed"))
|
||||
router.register_always(func(): order.append("always"))
|
||||
router.dispatch({"current_monologue": {"text": "Hi."}})
|
||||
assert_that(order[0]).is_equal("always")
|
||||
assert_that(order[1]).is_equal("keyed")
|
||||
|
||||
|
||||
# -- Empty dispatch ------------------------------------------------------------
|
||||
|
||||
func test_empty_snapshot_no_crash() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
router.register("foo", func(): pass)
|
||||
router.register_always(func(): pass)
|
||||
# Must not crash
|
||||
router.dispatch({})
|
||||
|
||||
|
||||
func test_no_handlers_no_crash() -> void:
|
||||
var router := SnapshotEventRouter.new()
|
||||
# Must not crash
|
||||
router.dispatch({"tick": 1, "entities": []})
|
||||
@@ -0,0 +1,261 @@
|
||||
## YamlParser unit tests (#560).
|
||||
## Verifies parse() (nested, typed) and parse_flat() (dotted keys, string values).
|
||||
## Covers: maps, nested maps, arrays of dicts, type conversion, comments,
|
||||
## quoted strings, empty input, edge cases.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestYamlParser
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- parse(): basic key-value pairs -------------------------------------------
|
||||
|
||||
func test_parse_simple_key_value() -> void:
|
||||
var result := YamlParser.parse("key: value")
|
||||
assert_that(result["key"]).is_equal("value")
|
||||
|
||||
|
||||
func test_parse_quoted_string() -> void:
|
||||
var result := YamlParser.parse('key: "hello world"')
|
||||
assert_that(result["key"]).is_equal("hello world")
|
||||
|
||||
|
||||
func test_parse_empty_quoted_string() -> void:
|
||||
var result := YamlParser.parse('key: ""')
|
||||
assert_that(result["key"]).is_equal("")
|
||||
|
||||
|
||||
func test_parse_integer_value() -> void:
|
||||
var result := YamlParser.parse("count: 42")
|
||||
assert_that(result["count"]).is_equal(42)
|
||||
assert_that(typeof(result["count"])).is_equal(TYPE_INT)
|
||||
|
||||
|
||||
func test_parse_negative_integer() -> void:
|
||||
var result := YamlParser.parse("offset: -3")
|
||||
assert_that(result["offset"]).is_equal(-3)
|
||||
|
||||
|
||||
func test_parse_float_value() -> void:
|
||||
var result := YamlParser.parse("radius: 2.5")
|
||||
assert_that(typeof(result["radius"])).is_equal(TYPE_FLOAT)
|
||||
assert_float(result["radius"]).is_equal_approx(2.5, 0.001)
|
||||
|
||||
|
||||
func test_parse_boolean_true() -> void:
|
||||
var result := YamlParser.parse("enabled: true")
|
||||
assert_that(result["enabled"]).is_equal(true)
|
||||
assert_that(typeof(result["enabled"])).is_equal(TYPE_BOOL)
|
||||
|
||||
|
||||
func test_parse_boolean_false() -> void:
|
||||
var result := YamlParser.parse("enabled: false")
|
||||
assert_that(result["enabled"]).is_equal(false)
|
||||
|
||||
|
||||
func test_parse_empty_input() -> void:
|
||||
var result := YamlParser.parse("")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_parse_only_comments() -> void:
|
||||
var result := YamlParser.parse("# comment\n# another")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_parse_inline_comment_stripped() -> void:
|
||||
var result := YamlParser.parse("room_id: test # this is a comment")
|
||||
assert_that(result["room_id"]).is_equal("test")
|
||||
|
||||
|
||||
func test_parse_quoted_value_with_hash() -> void:
|
||||
## Quoted strings preserve literal # characters.
|
||||
var result := YamlParser.parse('color: "#e0e8ff"')
|
||||
assert_that(result["color"]).is_equal("#e0e8ff")
|
||||
|
||||
|
||||
func test_parse_value_with_colon() -> void:
|
||||
var result := YamlParser.parse('time: "12:30"')
|
||||
assert_that(result["time"]).is_equal("12:30")
|
||||
|
||||
|
||||
# -- parse(): nested maps -----------------------------------------------------
|
||||
|
||||
func test_parse_nested_map() -> void:
|
||||
var yaml := "section:\n key: value"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result.has("section")).is_true()
|
||||
assert_that(result["section"] is Dictionary).is_true()
|
||||
assert_that(result["section"]["key"]).is_equal("value")
|
||||
|
||||
|
||||
func test_parse_deeply_nested() -> void:
|
||||
var yaml := "a:\n b:\n c: deep"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["a"]["b"]["c"]).is_equal("deep")
|
||||
|
||||
|
||||
func test_parse_multiple_sections() -> void:
|
||||
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["hud"]["mode"]).is_equal("Mode")
|
||||
assert_that(result["interaction"]["talk"]).is_equal("Talk")
|
||||
|
||||
|
||||
func test_parse_multiple_keys_per_section() -> void:
|
||||
var yaml := "hud:\n a: 1\n b: 2\n c: 3"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["hud"]["a"]).is_equal(1)
|
||||
assert_that(result["hud"]["b"]).is_equal(2)
|
||||
assert_that(result["hud"]["c"]).is_equal(3)
|
||||
|
||||
|
||||
func test_parse_sibling_subsections() -> void:
|
||||
var yaml := "states:\n a:\n x: 1\n b:\n x: 2"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["states"]["a"]["x"]).is_equal(1)
|
||||
assert_that(result["states"]["b"]["x"]).is_equal(2)
|
||||
|
||||
|
||||
func test_parse_section_with_trailing_comment() -> void:
|
||||
## "section: # comment" should be treated as a section header.
|
||||
var yaml := "section: # comment\n key: value"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["section"]["key"]).is_equal("value")
|
||||
|
||||
|
||||
# -- parse(): arrays of dicts -------------------------------------------------
|
||||
|
||||
func test_parse_single_array_item() -> void:
|
||||
var yaml := "conditions:\n - id: test-1\n type: near\n x: 10"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result.has("conditions")).is_true()
|
||||
assert_that(result["conditions"] is Array).is_true()
|
||||
assert_that(result["conditions"].size()).is_equal(1)
|
||||
assert_that(result["conditions"][0]["id"]).is_equal("test-1")
|
||||
assert_that(result["conditions"][0]["type"]).is_equal("near")
|
||||
assert_that(result["conditions"][0]["x"]).is_equal(10)
|
||||
|
||||
|
||||
func test_parse_multiple_array_items() -> void:
|
||||
var yaml := "conditions:\n - id: a\n x: 1\n - id: b\n x: 2"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["conditions"].size()).is_equal(2)
|
||||
assert_that(result["conditions"][0]["id"]).is_equal("a")
|
||||
assert_that(result["conditions"][1]["id"]).is_equal("b")
|
||||
|
||||
|
||||
func test_parse_array_items_with_blank_lines() -> void:
|
||||
var yaml := "conditions:\n - id: a\n x: 1\n\n - id: b\n x: 2"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["conditions"].size()).is_equal(2)
|
||||
|
||||
|
||||
func test_parse_top_level_plus_array() -> void:
|
||||
## Checklist format: top-level key-value pairs followed by a conditions array.
|
||||
var yaml := "room_id: warehouse\nconditions:\n - id: c1\n type: near\n x: 5"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["room_id"]).is_equal("warehouse")
|
||||
assert_that(result["conditions"].size()).is_equal(1)
|
||||
assert_that(result["conditions"][0]["id"]).is_equal("c1")
|
||||
|
||||
|
||||
func test_parse_array_typed_values() -> void:
|
||||
var yaml := "items:\n - id: t\n x: 42\n radius: 2.5\n active: true"
|
||||
var result := YamlParser.parse(yaml)
|
||||
var item: Dictionary = result["items"][0]
|
||||
assert_that(item["x"]).is_equal(42)
|
||||
assert_that(typeof(item["radius"])).is_equal(TYPE_FLOAT)
|
||||
assert_that(item["active"]).is_equal(true)
|
||||
|
||||
|
||||
# -- parse_flat(): dotted keys -------------------------------------------------
|
||||
|
||||
func test_flat_simple() -> void:
|
||||
var yaml := "section:\n key: value"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result.has("section.key")).is_true()
|
||||
assert_that(result["section.key"]).is_equal("value")
|
||||
|
||||
|
||||
func test_flat_deeply_nested() -> void:
|
||||
var yaml := "a:\n b:\n c: deep"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["a.b.c"]).is_equal("deep")
|
||||
|
||||
|
||||
func test_flat_multiple_sections() -> void:
|
||||
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["hud.mode"]).is_equal("Mode")
|
||||
assert_that(result["interaction.talk"]).is_equal("Talk")
|
||||
|
||||
|
||||
func test_flat_values_are_strings() -> void:
|
||||
## parse_flat returns all values as strings, unlike parse() which returns typed.
|
||||
var yaml := "section:\n count: 42\n rate: 0.9\n active: true"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["section.count"]).is_equal("42")
|
||||
assert_that(result["section.rate"]).is_equal("0.9")
|
||||
assert_that(result["section.active"]).is_equal("true")
|
||||
|
||||
|
||||
func test_flat_quoted_value() -> void:
|
||||
var yaml := 'section:\n key: "hello world"'
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["section.key"]).is_equal("hello world")
|
||||
|
||||
|
||||
func test_flat_inline_comment() -> void:
|
||||
var yaml := "hud:\n mode: Standard # default"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result["hud.mode"]).is_equal("Standard")
|
||||
|
||||
|
||||
func test_flat_empty_quoted_value() -> void:
|
||||
var yaml := 'hud:\n prefix: "" # No prefix'
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result.has("hud.prefix")).is_true()
|
||||
assert_that(result["hud.prefix"]).is_equal("")
|
||||
|
||||
|
||||
func test_flat_skips_arrays() -> void:
|
||||
## Arrays have no dotted-key representation — they are skipped in flat output.
|
||||
var yaml := "room_id: test\nconditions:\n - id: c1\n x: 5"
|
||||
var result := YamlParser.parse_flat(yaml)
|
||||
assert_that(result.has("room_id")).is_true()
|
||||
# No dotted keys for array contents
|
||||
assert_that(result.has("conditions")).is_false()
|
||||
assert_that(result.has("conditions.0")).is_false()
|
||||
|
||||
|
||||
func test_flat_empty_input() -> void:
|
||||
var result := YamlParser.parse_flat("")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Dialogue theme format (regression) ----------------------------------------
|
||||
|
||||
func test_dialogue_theme_format() -> void:
|
||||
## dialogue-theme.yaml has top-level values and one nested map (npc_colors).
|
||||
var yaml := "player_color: \"#e0e8ff\"\nnpc_colors:\n 0: \"#4a9ebb\"\n 1: \"#6bc9a6\"\npassive_opacity: 0.9"
|
||||
var flat := YamlParser.parse_flat(yaml)
|
||||
assert_that(flat["player_color"]).is_equal("#e0e8ff")
|
||||
assert_that(flat["npc_colors.0"]).is_equal("#4a9ebb")
|
||||
assert_that(flat["npc_colors.1"]).is_equal("#6bc9a6")
|
||||
assert_that(flat["passive_opacity"]).is_equal("0.9")
|
||||
|
||||
|
||||
# -- Checklist format (regression) ---------------------------------------------
|
||||
|
||||
func test_checklist_format() -> void:
|
||||
## checklist.yaml: top-level kv + conditions array with typed values.
|
||||
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["room_id"]).is_equal("inventory_warehouse")
|
||||
var cond: Dictionary = result["conditions"][0]
|
||||
assert_that(cond["id"]).is_equal("test-1")
|
||||
assert_that(cond["condition_type"]).is_equal("player_near")
|
||||
assert_that(cond["x"]).is_equal(10)
|
||||
assert_that(cond["y"]).is_equal(20)
|
||||
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
|
||||
@@ -0,0 +1,99 @@
|
||||
## Scene testing utilities for gdUnit4 tests.
|
||||
##
|
||||
## Loads a scene, instantiates it into the test suite's node tree,
|
||||
## and provides helpers for node existence, signal, and node-path queries.
|
||||
##
|
||||
## Usage (from a GdUnitTestSuite subclass):
|
||||
## var helper := SceneHelper.create(self, "res://scenes/main.tscn")
|
||||
## helper.assert_node_exists("World")
|
||||
## var world := helper.get_node_at("World")
|
||||
## helper.monitor_signal(world, "ready")
|
||||
## # ... trigger something ...
|
||||
## helper.assert_signal_emitted(world, "ready")
|
||||
##
|
||||
## Design constraints (D-030): server-free, no running autoload dependencies.
|
||||
class_name SceneHelper
|
||||
extends RefCounted
|
||||
|
||||
var _suite # GdUnitTestSuite — untyped to avoid load-order dependency
|
||||
var _scene: Node
|
||||
# signal_key -> int. Key is "<node_instance_id>:<signal_name>" for uniqueness.
|
||||
var _signal_hits: Dictionary = {}
|
||||
|
||||
|
||||
## Load, instantiate, and attach a scene to the test suite's node tree.
|
||||
## The scene node is registered for auto-free by gdUnit4.
|
||||
## Returns a helper instance; fails the test if the scene cannot be loaded.
|
||||
static func create(suite: GdUnitTestSuite, scene_path: String) -> SceneHelper:
|
||||
var helper := SceneHelper.new()
|
||||
helper._suite = suite
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
suite.assert_that(packed).override_failure_message(
|
||||
"SceneHelper: could not load scene at '%s'" % scene_path
|
||||
).is_not_null()
|
||||
return helper
|
||||
|
||||
helper._scene = packed.instantiate()
|
||||
suite.auto_free(helper._scene)
|
||||
suite.add_child(helper._scene)
|
||||
return helper
|
||||
|
||||
|
||||
## Returns the scene root node.
|
||||
func scene() -> Node:
|
||||
return _scene
|
||||
|
||||
|
||||
## Assert that a node at node_path exists under the scene root.
|
||||
## Fails the current test if the node is absent.
|
||||
func assert_node_exists(node_path: String) -> void:
|
||||
var node := _scene.get_node_or_null(NodePath(node_path))
|
||||
_suite.assert_that(node).override_failure_message(
|
||||
"SceneHelper: expected node at path '%s' — not found" % node_path
|
||||
).is_not_null()
|
||||
|
||||
|
||||
## Return the node at node_path under the scene root, or null if absent.
|
||||
func get_node_at(node_path: String) -> Node:
|
||||
return _scene.get_node_or_null(NodePath(node_path))
|
||||
|
||||
|
||||
## Begin tracking emissions of signal_name on node.
|
||||
## Must be called before the action that triggers the signal.
|
||||
## Fails the test if node does not have the named signal.
|
||||
func monitor_signal(node: Node, signal_name: String) -> void:
|
||||
if not node.has_signal(signal_name):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: node '%s' has no signal '%s'" % [node.name, signal_name]
|
||||
).is_true()
|
||||
return
|
||||
var key := _signal_key(node, signal_name)
|
||||
_signal_hits[key] = 0
|
||||
# Lambda accepts up to 4 positional args to tolerate signals with up to 4 params.
|
||||
# GDScript default-param lambdas handle being called with fewer args correctly.
|
||||
node.connect(signal_name, func(a := null, b := null, c := null, d := null):
|
||||
_signal_hits[key] = _signal_hits.get(key, 0) + 1
|
||||
)
|
||||
|
||||
|
||||
## Assert that signal_name was emitted at least once since monitor_signal().
|
||||
## Fails the test if monitor_signal() was not called first, or if count is zero.
|
||||
func assert_signal_emitted(node: Node, signal_name: String) -> void:
|
||||
var key := _signal_key(node, signal_name)
|
||||
if not _signal_hits.has(key):
|
||||
_suite.assert_that(false).override_failure_message(
|
||||
"SceneHelper: '%s' was not monitored — call monitor_signal() first" % signal_name
|
||||
).is_true()
|
||||
return
|
||||
var count: int = _signal_hits[key]
|
||||
_suite.assert_int(count).override_failure_message(
|
||||
"SceneHelper: signal '%s' on '%s' was not emitted (count=%d)" % [
|
||||
signal_name, node.name, count
|
||||
]
|
||||
).is_greater(0)
|
||||
|
||||
|
||||
static func _signal_key(node: Node, signal_name: String) -> String:
|
||||
return "%d:%s" % [node.get_instance_id(), signal_name]
|
||||
+17
-12
@@ -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:
|
||||
@@ -428,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()
|
||||
)
|
||||
@@ -443,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 --
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
extends Control
|
||||
## #258: Main menu — New Game / Continue / Quit.
|
||||
## New Game: generates per-game save directory (D-085), starts game.
|
||||
## Continue: loads most recent save directory (#554 full loading screen deferred).
|
||||
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const TITLE_COLOR := Color("#c8d0e0")
|
||||
const SUBTITLE_COLOR := Color("#8890a0")
|
||||
const BTN_NORMAL_COLOR := Color("#e8c547")
|
||||
const BTN_DISABLED_COLOR := Color("#4a5060")
|
||||
const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _quit_btn: Button = $VBox/QuitBtn
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
_continue_btn.pressed.connect(_on_continue)
|
||||
_quit_btn.pressed.connect(_on_quit)
|
||||
_refresh_continue_state()
|
||||
|
||||
|
||||
func _refresh_continue_state() -> void:
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
_continue_btn.disabled = saves.is_empty()
|
||||
|
||||
|
||||
func _on_new_game() -> void:
|
||||
var game_id := SessionManager.new_game()
|
||||
if game_id.is_empty():
|
||||
push_error("MainMenu: new_game() failed to create save directory — cannot start")
|
||||
return
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_continue() -> void:
|
||||
# #554: Full loading screen blocked until server SaveCommand lands.
|
||||
# For now: automatically load the most recent game directory.
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
if saves.is_empty():
|
||||
return
|
||||
SessionManager.resume_game(saves[0].game_id)
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
@@ -91,7 +91,7 @@ func _draw() -> void:
|
||||
var dx: float = float(poi.x) - px
|
||||
var dy: float = float(poi.y) - py
|
||||
var dist: float = sqrt(dx * dx + dy * dy)
|
||||
var category: String = poi.get("category", "")
|
||||
var category: String = poi.get("poi_category", "")
|
||||
var color: Color = _category_color(category)
|
||||
|
||||
if dist < 0.01:
|
||||
|
||||
@@ -35,6 +35,8 @@ const _LATTICE_COLORS: Dictionary = {
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
@@ -62,7 +64,10 @@ func _process(delta: float) -> void:
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if now >= _next_fade_in_msec:
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
if next.get("is_notification", false):
|
||||
_show_notification_line(next.text)
|
||||
else:
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
|
||||
|
||||
# Display a monologue line.
|
||||
@@ -71,6 +76,26 @@ func _process(delta: float) -> void:
|
||||
# Empty text is silently ignored — no slot created, no queue entry.
|
||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||
# decoupled from the autoload (D-020 renderer contract).
|
||||
# #554: Show a brief system notification (save/load result, connection status).
|
||||
# Uses neutral color, short duration, bypasses lattice_profile styling.
|
||||
func show_notification(text: String) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_notification_line(text)
|
||||
else:
|
||||
var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true}
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append(entry)
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
else:
|
||||
var lowest := _lowest_priority_idx()
|
||||
if 1 >= _queue[lowest].priority:
|
||||
_queue[lowest] = entry
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
|
||||
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
@@ -86,6 +111,35 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _show_notification_line(text: String) -> void:
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.add_theme_font_size_override("normal_font_size", 13)
|
||||
var safe_text := text.replace("[", "[lb]")
|
||||
label.text = "[color=#%s]%s[/color]" % [_NOTIFICATION_COLOR.to_html(false), safe_text]
|
||||
container.add_child(label)
|
||||
_vbox.add_child(container)
|
||||
var slot := {
|
||||
node = container,
|
||||
expire_timer = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
container.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
slot.tween = tween
|
||||
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC)
|
||||
|
||||
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
@@ -121,6 +121,14 @@ func _build_ui() -> void:
|
||||
close_btn.pressed.connect(close)
|
||||
_container.add_child(close_btn)
|
||||
|
||||
# Quit to Menu button (#258: D-085 session management)
|
||||
var quit_btn := Button.new()
|
||||
quit_btn.text = UIStrings.get_text("menu.quit_to_menu")
|
||||
quit_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
quit_btn.add_theme_color_override("font_color", Color("#c87040"))
|
||||
quit_btn.pressed.connect(_on_quit_to_menu)
|
||||
_container.add_child(quit_btn)
|
||||
|
||||
|
||||
func _destroy_ui() -> void:
|
||||
if _container:
|
||||
@@ -153,6 +161,11 @@ func _draw() -> void:
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
close()
|
||||
SessionManager.quit_to_menu()
|
||||
|
||||
|
||||
static func _format_db(db: float) -> String:
|
||||
if db <= -40.0:
|
||||
return "mute"
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../tooling/db
|
||||
+2
-2
@@ -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
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
+4
-4
@@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|
||||
|
||||
| File | Domain | Decisions |
|
||||
|------|--------|-----------|
|
||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088 |
|
||||
| [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 |
|
||||
| [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-039 |
|
||||
| [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
|
||||
|
||||
+103
-1
@@ -234,4 +234,106 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*18 decisions. Last updated: 2026-02-12 (D-088 added — retroactive filing from v0.1 Content Scoping Workshop)*
|
||||
### 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)*
|
||||
|
||||
+61
-1
@@ -227,4 +227,64 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
|
||||
---
|
||||
|
||||
*19 decisions. Last updated: 2026-02-12 (D-090, D-092 added — retroactive filings from v0.1 Content Scoping Workshop and Wiki Review Workshop)*
|
||||
### 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)*
|
||||
|
||||
+87
-1
@@ -242,6 +242,92 @@ Tracked questions awaiting discussion or resolution.
|
||||
- **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)
|
||||
|
||||
---
|
||||
|
||||
*39 questions (7 resolved, 1 partially resolved, 31 open). Last updated: 2026-02-25 (Q-030 through Q-039 added — retroactive filings from Wiki Review Workshop and v0.1 Content Scoping Workshop)*
|
||||
### 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)*
|
||||
|
||||
+6
-6
@@ -269,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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# Spatial Layout: Gate Cluster (Span Gate Processing Facility)
|
||||
|
||||
**Ticket:** #153
|
||||
**Date:** 2026-02-25
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Status:** v0.1 — wireframe quality, unblocks copy and gate cluster NPC authoring
|
||||
|
||||
**Grid:** 1 cell = 1m visual tile (visual grammar §2.1). Simulation operates at 0.5m; each visual tile = 2×2 sim tiles.
|
||||
**Map area:** 40m wide × 32m deep (40×32 visual tiles) + observation gallery on z=2
|
||||
**Zone palette:** Cool institutional grey — Era 3 construction, Commission-grade maintenance (visual grammar §1.1)
|
||||
|
||||
---
|
||||
|
||||
## Spatial Character
|
||||
|
||||
The gate cluster is the newest structure in the Transit District. Era 3 construction: clean sightlines, uniform LED-white overhead lighting, minimal accumulated grime. Commission-monitored and Commission-maintained. Where the Terminal reads as institutional but worn, and the Bar as warm and accumulated, the gate cluster reads as **administered**. The architecture communicates that someone is watching.
|
||||
|
||||
The building is a funnel. The span gate aperture (~15–20m diameter ring) determines the widest point; the passenger and freight flows narrow through processing stages; they emerge into the gate concourse, which opens outward as public space. The spatial logic is deliberate: volume at intake, compression through customs, expansion at public exit.
|
||||
|
||||
Dual-use scheduling is the spatial and operational premise (D-095): freight windows and passenger windows share the single aperture. The "flicker" (90-second mode transition between sequences) is legible to experienced travelers — different lighting cues on the aperture chamber walls mark which mode is active.
|
||||
|
||||
**G-11 entry note:** The detective arrives via this cluster from a Commission shuttle. Workers arrive via the transit platform on the bar side. These are structurally separated entry vectors. The gate cluster is the detective's first experience of the district.
|
||||
|
||||
---
|
||||
|
||||
## Floor Plan
|
||||
|
||||
### z=1 (Ground Floor)
|
||||
|
||||
```
|
||||
N (span gate aperture — external, connects to The Ring)
|
||||
|
|
||||
1111111111222222222233333333334444444
|
||||
1234567890123456789012345678901234567890
|
||||
|
||||
############[APERTURE RING]######### row 01 <- span gate ring (structural boundary)
|
||||
# . . . APERTURE CHAMBER . . . # row 02
|
||||
# . . . . . . . . . . . . . . # row 03 ACCESS: RESTRICTED
|
||||
# . . . . . . . . . . . . . . # row 04 (airlock/transition zone)
|
||||
########[D]##########[D]############ row 05 <- chamber exit doors (freight W, passenger E)
|
||||
|
||||
############################[D]###### row 06 <- freight staging north wall (east door = PAB)
|
||||
# FREIGHT STAGING # PAB # row 07
|
||||
# [FK][FK] [FK][FK] . # . # row 08 ACCESS: private (freight) / semi-public (PAB)
|
||||
# [FK][FK] [FK][FK] . # . # row 09 PAB = Passenger Arrival Buffer
|
||||
# [FK][FK] [FK][FK] . # . # row 10
|
||||
# . . . . . . . . [CT][CT] # . # row 11 <- CT = cargo transporter dock points
|
||||
# . . . . . . . . [CT][CT] # . # row 12 <- PAB merges south into customs at row 13
|
||||
#####[D]####################[D]###### row 13 <- into customs lanes
|
||||
|
||||
###################[D]############### row 14 <- freight customs north entry
|
||||
# FCL | FCL | FCL | FCL | FCL # row 15 ACCESS: semi-private (freight customs)
|
||||
# [TS] | [TS] | [TS] | [TS] | [TS] # row 16 FCL = freight customs lane (5 lanes × 4vt)
|
||||
# || | || | || | || | || # row 17 TS = terminal/scanner station per lane
|
||||
# || | || | || | || | || # row 18 || = cargo lane (4vt wide, column breaks Q4)
|
||||
# [P] | [P] | [P] | [P] | [P] # row 19 P = pillar/LOS anchor (4-tile interval)
|
||||
# . . .|. . . |. . . |. . . |. . . # row 20 <- inspection floor
|
||||
#######|#######[D]####[D]###|######## row 21 <- customs south wall; PCL entry
|
||||
# PCL PCL PCL PCL PCL # row 22 ACCESS: semi-public (pedestrian customs)
|
||||
# [TS] [TS] [TS] [TS] [TS] . . # row 23 PCL = pedestrian customs lanes (3 lanes × 2vt)
|
||||
# [P] . . [P] . . [P] . . # row 24 <- queue markers + pillar anchors
|
||||
# . . . . . . . . . . . . . . . # row 25
|
||||
# . . . . . . . . . . . . . . . # row 26
|
||||
############[D]####[D]############### row 27 <- customs south doors to concourse
|
||||
|
||||
#################################### row 28 <- concourse north wall
|
||||
# . . [B] [B] . . [NT][NT] # row 29 ACCESS: public
|
||||
# . . . . . . . . . . # row 30 B = bench, NT = news ticker
|
||||
# . . [B] [B] . . . . . # row 31
|
||||
# . . . . . . . . [D]SC # row 32 <- staircase (SC) east end; Commission entry
|
||||
#################################### row 33 <- concourse south wall (district entry facade)
|
||||
|
||||
|
|
||||
S (district interior — Terminal forecourt, transition corridor)
|
||||
```
|
||||
|
||||
**Legend:**
|
||||
```
|
||||
# Wall (solid, blocks LOS and movement)
|
||||
. Open walkable floor
|
||||
[D] Doorway (traversable)
|
||||
[APERTURE RING] Span gate ring structure (impassable during transit; open between sequences)
|
||||
FCL Freight customs lane
|
||||
PCL Pedestrian customs lane
|
||||
[TS] Terminal/scanner station (customs clerk workstation)
|
||||
[FK] Freight staging kiosk / forwarder terminal
|
||||
[CT] Cargo transporter dock point (loading/unloading position)
|
||||
[B] Bench (public seating)
|
||||
[NT] News ticker display (wall-mounted)
|
||||
[P] Structural pillar (LOS anchor, column break, gallery support above)
|
||||
SC Staircase to z=2 observation gallery (east end of concourse)
|
||||
PAB Passenger Arrival Buffer (east of freight staging, rows 06–12)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### z=2 (Observation Gallery) — Commission-only
|
||||
|
||||
```
|
||||
N
|
||||
|
|
||||
(above customs lanes — rows 14–27 below)
|
||||
1111111111222222222233333333334444
|
||||
1234567890123456789012345678901234567
|
||||
|
||||
[GALLERY NORTH RAIL — partial glass/grating]
|
||||
################################# <- gallery west and east walls
|
||||
# . . . . GALLERY FLOOR . . . # Commission-only: pristine near-white
|
||||
# [DK][DK] . . . [DK][DK] # DK = observation desk / surveillance kit
|
||||
# . . . . . . . . . . . . . . # floor: #d4d8dc
|
||||
# . . . . . . . . . . . . . . # walls: #e0e4e8
|
||||
# [DK][DK] . . . [DK][DK] #
|
||||
# . . . . . . . . . . . . . . #
|
||||
# . . . . . . . . . . . . . . #
|
||||
# . . . . . . . . . . . . . . #
|
||||
################################# <- gallery south rail (partial glass/grating)
|
||||
|
|
||||
[SC] staircase descends to z=1 concourse east end
|
||||
|
|
||||
S
|
||||
```
|
||||
|
||||
**Gallery dimensions:** 32m wide × 10m deep (32×10 visual tiles). Positioned above the customs lanes (z=1 rows 14–27) and NOT above the concourse or staging zones.
|
||||
|
||||
**Cross-z LOS:** Gallery rail is transparent low wall (glass or metal grating). Observer on z=2 has LOS downward to z=1 customs lanes. Upward LOS from z=1 is blocked except at the staircase opening. Players cannot see gallery occupants from the customs floor unless standing at the staircase.
|
||||
|
||||
**Gallery floor is the customs ceiling** — approximately 4m structural clearance below.
|
||||
|
||||
---
|
||||
|
||||
## Zone Breakdown
|
||||
|
||||
### Zone 1 — Aperture Chamber (rows 01–05)
|
||||
**Dimensions:** 40×4 visual tiles
|
||||
**Access tier:** Restricted (Commission control + gate authority; no public entry)
|
||||
**Purpose:** The transition space between the span gate aperture and the main processing facility. All passengers and freight pass through here immediately after emerging from the span gate. The aperture ring is the physical gate structure — when a transit sequence is active, the ring glows with transit residue (lighting cue for mode). Between sequences, the ring is dark and cold.
|
||||
**NPC traffic:** Gate authority staff (2–3 stationed here per sequence). Arrivals flow through continuously during an active sequence; zero traffic between sequences.
|
||||
**LOS notes:** The chamber is enclosed. No LOS to any other zone except the two exit doors (row 05). Gate authority staff can observe the full chamber volume. No LOS from the staging zones into the chamber.
|
||||
**Key feature:** The "flicker" — the 90-second mode transition between freight and passenger sequences — is physically visible here. Lighting shifts, personnel rotate, cargo equipment is cleared or staged. An observer in the gate concourse (south) can hear the mode change but not see it.
|
||||
|
||||
### Zone 2 — Freight Staging (rows 06–13, west 24 tiles)
|
||||
**Dimensions:** 24×8 visual tiles
|
||||
**Access tier:** Private (authorized freight operators and customs personnel only)
|
||||
**Purpose:** Where inbound freight is offloaded, registered, and staged for the customs inspection lanes. [FK] forwarder terminals are where freight agents log their manifest declarations before the cargo moves to the lanes. [CT] dock points are active during freight windows; they are dormant (low power, no staff) during passenger windows.
|
||||
**NPC traffic:** Busy during freight windows. The operations manager (Triangle NPC) is typically here during active freight sequences — their role is coordinating flow from aperture to customs. Senior freight handlers work the dock points. Sparse during passenger windows.
|
||||
**LOS notes:** Full LOS across the staging floor from the forwarder terminals. The freight customs entry door (row 13) is visible from the staging area. The PAB door (east) is visible but the PAB interior is not.
|
||||
**Key feature:** The operations manager's position here — with LOS to the aperture chamber exits, the staging floor, and the customs entry — is the spatial expression of their authority. They see everything that comes in.
|
||||
|
||||
### Zone 3 — Passenger Arrival Buffer (rows 06–12, east 12 tiles)
|
||||
**Dimensions:** 12×8 visual tiles
|
||||
**Access tier:** Semi-public (arriving passengers only; no unauthorized entry from district side)
|
||||
**Purpose:** Where passengers emerging from the span gate are held in a staging queue before processing through pedestrian customs. Separate from freight staging — the physical separation is the architectural enforcement of D-095's dual-use windows. During a freight window, the PAB is closed; during a passenger window, it fills.
|
||||
**NPC traffic:** Moderated by gate sequence. Full during a passenger window; empty between or during freight windows.
|
||||
**LOS notes:** LOS within the PAB is full. No direct LOS from the district concourse into the PAB — the customs lanes form a visual barrier. A player in the concourse sees only the south face of the customs lane structure.
|
||||
**Key feature:** The detective's entry experience begins here (G-11). Arriving via Commission shuttle during a passenger window, they queue briefly before being waved through customs (or escorted directly to the observation gallery — see staircase at z=1 east end).
|
||||
|
||||
### Zone 4 — Freight Customs Lanes (rows 14–21, west 20 tiles)
|
||||
**Dimensions:** 20×10 visual tiles (5 lanes × 4vt each, within a 20vt-wide zone, rows 14–21)
|
||||
**Access tier:** Semi-private (freight operators entering the district; customs clerk staff)
|
||||
**Purpose:** Processing incoming freight through customs inspection. Five lanes, each 4 visual tiles wide, each staffed by a customs clerk at a [TS] terminal station. [P] pillars at 4-tile intervals serve dual purpose: LOS anchors for the customs floor and structural supports for the observation gallery above.
|
||||
**NPC traffic:** Customs clerks (5, one per lane) are stationed here during freight windows. The Commission inspector (Triangle NPC) circulates among lanes — their social dynamic with the clerks is expressed spatially by where they position themselves during inspections. During passenger windows, lanes are closed (screens down, no staff).
|
||||
**LOS notes:** Clear LOS along each lane from north wall to south wall. The pillar breaks ([P]) interrupt cross-lane LOS at 4-tile intervals — an observer cannot see continuously across all 5 lanes. The gallery rail above (z=2) allows the Commission inspector to observe all lanes simultaneously from elevation. This is the critical asymmetry: floor-level observers have partial LOS; gallery observers have full LOS.
|
||||
**Observation note:** The social triangle's power dynamic is visible in sightlines. The Commission inspector from the gallery sees the customs clerks in their entirety, including which freight forwarders are waved through vs. searched. The floor-level operations manager sees individual lanes but not the full picture. The detective, arriving from the gallery, can observe the customs floor before descending.
|
||||
|
||||
### Zone 5 — Pedestrian Customs Lanes (rows 22–27, east 12 tiles)
|
||||
**Dimensions:** 12×10 visual tiles (3 lanes × 2vt each, with queue space to east, rows 22–27)
|
||||
**Access tier:** Semi-public (arriving passengers processing into the district)
|
||||
**Purpose:** Processing arriving passengers through customs. Three lanes, each 2 visual tiles wide, each with a [TS] scanner station. Queue space runs east of the lane structure. Simpler operation than freight customs — personal items scan, biometric check, manifest tag if applicable.
|
||||
**NPC traffic:** Active only during passenger windows. During freight windows, the customs clerks from PCL rotate to assist with FCL overflow. The Commission inspector may operate from PCL during passenger windows if intelligence suggests surveillance value.
|
||||
**LOS notes:** Narrower lanes mean LOS is more constrained. An observer in the queue can see only the lane directly ahead. From the gate concourse (south), the south face of the customs structure presents as a low partition — passengers emerging from customs are visible from the concourse immediately on exit.
|
||||
**Key feature:** This is where observable inequity happens. The Commission inspector (or a directive they issue) results in one class of traveler being waved through while another is searched. This is visible to anyone in the concourse queue area — including the detective. The spatial proximity of the PCL south wall to the concourse benches [B] means concourse passengers witness the processing of arrivals.
|
||||
|
||||
### Zone 6 — Gate Concourse (rows 28–33)
|
||||
**Dimensions:** 40×8 visual tiles (full building width)
|
||||
**Access tier:** Public (all district residents, workers, and new arrivals)
|
||||
**Purpose:** The public-facing terminus of the gate cluster. Benches [B] for waiting passengers, news ticker [NT] on the east wall for transit schedules and general news, and the primary facade opening to the district south. The staircase (SC) at the east end is the access point to the observation gallery — it is Commission-coded at the base (a discreet panel, not a visible barrier).
|
||||
**NPC traffic:** Variable. Busy when a passenger sequence has just completed (arrivals dispersing). Sparse during freight windows (only workers and officials). The concourse is the natural convergence zone for all district-side personnel who have business at the gate cluster.
|
||||
**LOS notes:** Full east-west LOS across the concourse. The [P] pillars from the customs lanes above do not extend to the concourse floor — the south edge of the customs structure is a visual wall at row 27. From the benches, observers can see the customs exit doors (row 27) and watch arrivals emerge. Cannot see into customs lanes from bench positions.
|
||||
**Key feature:** The social reading zone on arrival. New arrivals (including the detective on their first visit) experience the concourse before moving into the district. The news ticker is a topic generator. The Commission staircase (east end) is present but low-key — coded access does not broadcast itself in Commission-grade facilities.
|
||||
|
||||
### Zone 7 — Observation Gallery (z=2, above zones 4–5)
|
||||
**Dimensions:** 32×10 visual tiles (above the full customs lane section)
|
||||
**Access tier:** Commission-only (staircase coded at z=1 east end of concourse)
|
||||
**Purpose:** The gallery is where the Commission inspector works during active processing sequences. From here, all freight and pedestrian customs lanes are simultaneously observable. Observation desks [DK] with surveillance kit allow real-time customs monitoring, camera feed access, and communication with gate authority staff below. This is the institutional oversight position — the spatial embodiment of Commission authority over district entry.
|
||||
**NPC traffic:** The Commission inspector during work hours. Possibly a second Commission observer (junior) — but sparse. This is not a social space; it is a surveillance position.
|
||||
**LOS notes:** Full LOS down to all customs lanes (z=2 → z=1, through gallery rail). Partial LOS to freight staging (row 13 door visible from gallery north rail). NO LOS to aperture chamber (wall blocks), NO LOS to concourse (gallery south rail is opaque below rail height). Gallery interior has no LOS from the customs floor below — the cross-z asymmetry is deliberate and complete.
|
||||
**Key feature:** The detective's first introduction to this space is via escort through the staircase. The experience of descending from gallery (full picture) to concourse (partial picture) is the spatial tutorial for the information asymmetry theme.
|
||||
|
||||
---
|
||||
|
||||
## Key Observation Positions
|
||||
|
||||
| Position | Code | LOS coverage | Why it matters |
|
||||
|----------|------|-------------|----------------|
|
||||
| Observation gallery (z=2, center) | POS-G1 | All freight + pedestrian customs lanes simultaneously | Commission inspector's domain. Highest-information position in the gate cluster. Asymmetric — not visible from below. |
|
||||
| Freight staging floor (center) | POS-G2 | Aperture chamber exits, freight staging, customs entry door | Operations manager's natural position. Sees intake and output but not gallery or pedestrian lanes. |
|
||||
| Gate concourse benches (rows 29-30, west) | POS-G3 | Customs exit doors (row 27), staircase base (east), full concourse | Player's first investigation position. Passive observation of arrivals emerging from customs and who accesses the staircase. |
|
||||
| Pedestrian customs queue (row 22, east) | POS-G4 | PCL lanes, customs exit direction | Observer in queue can watch the customs clerks processing arrivals. Visible inequity in who is waved through vs. searched. |
|
||||
| Concourse east end (near staircase) | POS-G5 | Staircase access panel, anyone ascending/descending | Monitoring staircase access reveals Commission movement. Coded panel is discreet but observable. |
|
||||
| Gallery north rail (z=2) | POS-G6 | Freight staging floor through rail, customs lane north entries | Extended north-viewing position from gallery — tracks cargo from aperture exit to lane entry. |
|
||||
|
||||
---
|
||||
|
||||
## Sightline Analysis
|
||||
|
||||
```
|
||||
FROM → Aperture Frt.Stg. PAB Frt.Cust. Ped.Cust. Concourse Gallery
|
||||
TO ↓
|
||||
Aperture SELF via door via door NO NO NO NO
|
||||
Frt.Staging via door SELF via door via door NO NO NO
|
||||
PAB via door via door SELF NO NO NO NO
|
||||
Frt.Customs NO via door NO SELF NO via door rail(z2→z1)
|
||||
Ped.Customs NO NO NO NO SELF via door rail(z2→z1)
|
||||
Concourse NO NO NO via door via door SELF NO
|
||||
Gallery NO rail(N) NO rail(full) rail(full) NO SELF
|
||||
|
||||
rail(z2→z1) = LOS from gallery down through transparent rail/grating
|
||||
rail(N) = gallery north rail has partial LOS to freight staging floor
|
||||
NO = wall or z-gap blocks
|
||||
via door = LOS when door open
|
||||
```
|
||||
|
||||
**Critical sightline: Gallery → all customs lanes**
|
||||
The Commission inspector on z=2 has full LOS over every freight and pedestrian customs lane simultaneously. No position on the z=1 customs floor achieves equivalent coverage. This asymmetry is the spatial expression of institutional oversight.
|
||||
|
||||
**Critical sightline gap: Concourse → customs interior**
|
||||
The concourse benches are south of the customs structure. The customs south wall (rows 14–21 for freight, 22–27 for pedestrian) presents as a visual barrier. A player on the benches sees the customs exit doors and emerging arrivals — but not what happens inside the lanes. Investigation of customs behavior requires entering the lanes or reaching the gallery.
|
||||
|
||||
**Critical sightline gap: Gallery → concourse**
|
||||
The gallery south rail is opaque below the rail height. The Commission inspector cannot observe the concourse from the gallery without descending. The gallery is a surveillance position for entry processing, not for the public space.
|
||||
|
||||
---
|
||||
|
||||
## Access Tier Map
|
||||
|
||||
```
|
||||
RESTRICTED PRIVATE SEMI-PRIVATE SEMI-PUBLIC PUBLIC
|
||||
────────── ─────── ──────────── ─────────── ──────
|
||||
Aperture Freight staging Freight customs Ped. customs Concourse
|
||||
chamber (auth. operators) lanes lanes (all)
|
||||
(clerks + (arriving
|
||||
[Gallery z=2: freight ops) passengers)
|
||||
Commission-only]
|
||||
```
|
||||
|
||||
Sequential access enforcement (H-06): A freight operator moving from aperture to district must pass through freight staging → freight customs → concourse. No spatial path skips a tier. Pedestrian arrivals pass through PAB → pedestrian customs → concourse. The two flows are physically separated (west half vs. east half of the building) and join only at the concourse.
|
||||
|
||||
---
|
||||
|
||||
## NPC Traffic Density Annotations
|
||||
|
||||
| Time | Aperture | Frt. Staging | PAB | Frt. Customs | Ped. Customs | Concourse | Gallery |
|
||||
|------|----------|-------------|-----|-------------|-------------|-----------|---------|
|
||||
| Dawn (05-07) | very sparse | very sparse | closed | closed | closed | very sparse | — |
|
||||
| Freight window 1 (07-12) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
|
||||
| Passenger window (12-14) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
|
||||
| Freight window 2 (14-19) | busy (freight) | busy | closed | busy | closed | moderate | inspector |
|
||||
| Passenger window (19-20) | moderate (pax) | sparse | moderate | closed | moderate | busy | inspector |
|
||||
| Evening sparse (20-23) | sparse | sparse | closed | sparse | closed | sparse | varies |
|
||||
| Night (23-05) | very sparse | very sparse | closed | closed | closed | very sparse | — |
|
||||
|
||||
**Flicker windows:** 90-second transition between freight and passenger modes. During this window:
|
||||
- Aperture chamber resets (personnel exchange, lighting shifts, cargo equipment cleared or staged)
|
||||
- All customs lanes briefly closed
|
||||
- Concourse becomes transiently busier as travelers waiting for mode completion gather
|
||||
- The operations manager is most exposed — coordinating the reset, moving between staging and customs entry
|
||||
|
||||
**Detective entry:** Commission shuttles arrive during passenger windows as a matter of protocol. First contact with the district begins in the aperture chamber, proceeds to the PAB, and typically diverts to the gallery staircase before customs processing is required.
|
||||
|
||||
---
|
||||
|
||||
## Narrative Triangle Service Notes
|
||||
|
||||
### Triangle 5 — Gate Authority (Operations Manager – Senior Freight Handler – Commission Inspector)
|
||||
|
||||
This is an institutional-oversight triangle, structurally different from the Terminal's knowledge-and-leverage triangles (1–2) and the Bar's social-loyalty triangles (3–4).
|
||||
|
||||
- **Operations manager:** Their domain is the freight flow — aperture to staging to customs. They have private-tier access everywhere on the z=1 floor. They are measured by throughput: how much cargo clears customs in a window. They have an accommodation relationship with certain freight forwarders (see customs inequity below).
|
||||
- **Senior freight handler:** The forwarder who benefits from that accommodation. They know what they get, they know why, and they know the operations manager knows they know. This is the stable complicity leg of the triangle.
|
||||
- **Commission inspector:** Their domain is the gallery. They watch the customs floor from above. They may know about the accommodation, or may be about to discover it, or may be using it as leverage already. Their relationship to the operations manager is formally collaborative, actually adversarial.
|
||||
|
||||
**Spatial expression:** The operations manager never goes to the gallery. The Commission inspector rarely comes to the floor. The senior freight handler is on the floor. The triangle's tension is mediated by the cross-z sightline — the inspector can see the forwarder being waved through, and the operations manager knows the inspector is watching, but neither will acknowledge it in the same zone at the same time.
|
||||
|
||||
**Observable inequity (investigation entry point):** A player watching from the concourse benches (POS-G3) or from the pedestrian customs queue (POS-G4) can observe a freight forwarder being waved through freight customs without search while a commuter on the pedestrian side receives a full scan. This is not dramatic — it reads as normal. The player has to make the connection: waved through = known cargo = manifested incorrectly = this is where the lattice components enter.
|
||||
|
||||
**Tension staging locations:**
|
||||
- Freight staging floor (operations manager's ground; conversations here are authority-neutral)
|
||||
- Gallery (inspector's ground; a summons to the gallery is pressure)
|
||||
- Customs lane (the observable action space; what clerks actually do is determined by unspoken directives from above)
|
||||
- Concourse east end near staircase (the transition space; anyone ascending the staircase must pass anyone watching the staircase)
|
||||
|
||||
---
|
||||
|
||||
## Z-Level Notes
|
||||
|
||||
**z=0:** Not present in the gate cluster. Maintenance access to the gate cluster, if any, is via the district maintenance spine (z=0 elsewhere in district) and does not extend into the gate cluster interior. Era 3 construction has no maintenance corridor integration — maintenance occurs from above via service panels.
|
||||
|
||||
**z=1:** All gate cluster zones: aperture chamber, freight staging, PAB, freight customs, pedestrian customs, concourse. All NPCs and player movement on this level.
|
||||
|
||||
**z=2:** Observation gallery only. Staircase is the sole connection point (z=1 concourse east end ↔ z=2 gallery). Commission-coded access panel at base of staircase is present but low-profile (no visible lock, no visible panel labeling in Era 3 style — access is granted by insertion of Commission neural-tag proximity, not a key).
|
||||
|
||||
**Cross-z visibility rules:**
|
||||
- Gallery → customs lanes: full downward LOS through transparent rail/grating
|
||||
- Customs lanes → gallery: no upward LOS (gallery floor solid except rail; rail height above standing head height)
|
||||
- Staircase opening: local LOS only (see who is at the staircase base or top, not into gallery interior)
|
||||
|
||||
---
|
||||
|
||||
## Notes for Copy Team
|
||||
|
||||
1. **Aperture chamber is the in-world ritual.** Arriving via span gate is Commonwealth-mundane but the aperture ring has residual energy effects — ambient hum, slight color temperature shift as light normalizes from transit. Monologue lines for the detective's arrival should note this. Standard sensory detail for immersive-world arrivals.
|
||||
2. **The customs inequity is not dramatic.** When the senior freight handler is waved through, it should read as routine from NPC behavior — a nod, a scan confirmed, the lane opens. Overheard dialogue, if any, should be procedural: manifest check language, not conversational. The player learns that something is wrong from the pattern, not from a flagrant scene.
|
||||
3. **The Commission inspector's gallery is their professional comfort zone.** Dialogue set in the gallery (if the detective accesses it) should reflect this — the inspector is at ease up here, slightly less guarded. On the floor, they are performing authority. In the gallery, they are just watching.
|
||||
4. **The operations manager on the freight staging floor.** This is their element. Logistics language, shorthand with the senior freight handler. Any casual conversation with the detective here is the operations manager on home turf — helpful enough, not forthcoming.
|
||||
5. **The flicker is an ambient event.** Travelers who know the schedule stop and wait. Travelers who don't know find themselves in a 90-second limbo — nothing is moving, customs is closed. The concourse fills briefly. Use this as a social compression beat: forced proximity, idle waiting, overheard conversations that wouldn't happen mid-flow.
|
||||
6. **The staircase is visible, not obvious.** In Era 3 design language, it is clean, architectural, slightly more refined than the surrounding fittings. It doesn't broadcast Commission. Players who are paying attention will notice it; players who aren't will miss it. Second visit = "wait, I didn't see that last time."
|
||||
@@ -0,0 +1,235 @@
|
||||
# District Topology — Sova Transit District
|
||||
# D-093 spatial layout. D-094 hierarchy: 256×256 vt (4×4 blocks).
|
||||
# North = span gate entry. South-east = tram entry.
|
||||
|
||||
direction: down
|
||||
|
||||
vars: {
|
||||
bg: "#1a1e24"
|
||||
txt: "#c8d0e0"
|
||||
acc: "#c8d8f0"
|
||||
|
||||
pub: "#1a3320"
|
||||
spub: "#2e2a10"
|
||||
spriv: "#2e1a08"
|
||||
priv: "#2a0e0e"
|
||||
comm: "#0e1a2e"
|
||||
neut: "#1e2228"
|
||||
|
||||
s-pub: "#3a8a50"
|
||||
s-spub: "#b8a020"
|
||||
s-spriv: "#c86010"
|
||||
s-priv: "#c02020"
|
||||
s-comm: "#3060c0"
|
||||
s-neut: "#4a5060"
|
||||
}
|
||||
|
||||
# ── LEGEND ──
|
||||
|
||||
legend: Legend {
|
||||
style.fill: ${bg}
|
||||
style.stroke: ${acc}
|
||||
style.font-color: ${txt}
|
||||
style.font-size: 11
|
||||
direction: right
|
||||
|
||||
l1: Public { style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt} }
|
||||
l2: Semi-pub { style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt} }
|
||||
l3: Semi-priv { style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt} }
|
||||
l4: Private { style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt} }
|
||||
l5: Commission { style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt} }
|
||||
}
|
||||
|
||||
# ── NORTH ENTRY: SPAN GATE ──
|
||||
|
||||
span_gate: The Ring\n(span gate aperture) {
|
||||
shape: hexagon
|
||||
style.fill: ${comm}
|
||||
style.stroke: ${s-comm}
|
||||
style.font-color: ${txt}
|
||||
}
|
||||
|
||||
# ── GATE CLUSTER ──
|
||||
|
||||
gate: Gate Cluster · 40×32 vt {
|
||||
style.fill: ${bg}
|
||||
style.stroke: ${s-comm}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
|
||||
aperture: Aperture\n8×4 {
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
staging: Freight Staging\n24×8 {
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
pab: Passenger Arrival\n12×8 {
|
||||
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
|
||||
}
|
||||
customs: Customs Lanes\n(freight 5×4vt + ped 3×2vt) {
|
||||
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
|
||||
}
|
||||
concourse: Concourse\n40×8 · PUBLIC {
|
||||
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
|
||||
}
|
||||
gallery: Gallery · z=2\n32×10 · COMMISSION {
|
||||
style.fill: ${comm}; style.stroke: ${s-comm}; style.font-color: ${txt}
|
||||
}
|
||||
|
||||
aperture -> staging: freight { style.stroke: ${s-priv} }
|
||||
aperture -> pab: passenger { style.stroke: ${s-spub} }
|
||||
staging -> customs { style.stroke: ${s-spriv} }
|
||||
pab -> customs { style.stroke: ${s-spub} }
|
||||
customs -> concourse { style.stroke: ${s-pub} }
|
||||
concourse -> gallery: "staircase (Commission)" {
|
||||
style.stroke: ${s-comm}; style.stroke-dash: 4
|
||||
}
|
||||
gallery -> customs: "LOS z=2 down" {
|
||||
style.stroke: ${s-comm}; style.stroke-dash: 4
|
||||
}
|
||||
}
|
||||
|
||||
span_gate -> gate.aperture: "dual-use transit\n(90s flicker)" {
|
||||
style.stroke: ${s-comm}
|
||||
}
|
||||
|
||||
# ── TERMINAL ──
|
||||
|
||||
terminal: Terminal · 44×28 vt {
|
||||
style.fill: ${bg}
|
||||
style.stroke: ${s-spriv}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
|
||||
forecourt: Forecourt\n44×4 {
|
||||
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
|
||||
}
|
||||
cargo: Cargo Floor + Main Corridor {
|
||||
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
|
||||
}
|
||||
storage: Restricted Storage\n(single coded door) {
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
hatch_t: M-HATCH-T {
|
||||
shape: diamond
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
|
||||
forecourt -> cargo { style.stroke: ${s-spriv} }
|
||||
cargo -> storage: "coded door" { style.stroke: ${s-priv} }
|
||||
storage -> hatch_t { style.stroke: ${s-priv} }
|
||||
}
|
||||
|
||||
gate.concourse -> terminal.forecourt: "district spine (south)" {
|
||||
style.stroke: ${s-pub}
|
||||
}
|
||||
|
||||
# ── TRANSITION CORRIDOR ──
|
||||
|
||||
corridor: Transition Corridor\n~40×6 vt {
|
||||
style.fill: ${spub}
|
||||
style.stroke: ${s-spub}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
terminal.cargo -> corridor: "public route" {
|
||||
style.stroke: ${s-spub}
|
||||
}
|
||||
|
||||
# ── BAR ──
|
||||
|
||||
bar: The Last Shift · 28×22 vt {
|
||||
style.fill: ${bg}
|
||||
style.stroke: ${s-pub}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
|
||||
approach: Bar Approach\n28×3 {
|
||||
style.fill: ${spub}; style.stroke: ${s-spub}; style.font-color: ${txt}
|
||||
}
|
||||
floor: Main Floor\n(corner booth · card table) {
|
||||
style.fill: ${pub}; style.stroke: ${s-pub}; style.font-color: ${txt}
|
||||
}
|
||||
bathroom: Bathroom Corridor\n(east ext.) {
|
||||
style.fill: ${spriv}; style.stroke: ${s-spriv}; style.font-color: ${txt}
|
||||
}
|
||||
backroom: Back Room\n(Lera) {
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
hatch_b: M-HATCH-B {
|
||||
shape: diamond
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
|
||||
approach -> floor { style.stroke: ${s-pub} }
|
||||
floor -> bathroom: "east door" { style.stroke: ${s-spriv} }
|
||||
floor -> backroom: "staff only" { style.stroke: ${s-priv} }
|
||||
bathroom -> hatch_b { style.stroke: ${s-priv} }
|
||||
}
|
||||
|
||||
corridor -> bar.approach: "bar-side decompression" {
|
||||
style.stroke: ${s-spub}
|
||||
}
|
||||
|
||||
# ── MAINTENANCE CORRIDOR (z=0) ──
|
||||
|
||||
maint: Maintenance Corridor\n(z=0 · Era 1 · 2vt wide)\nzero public traffic {
|
||||
style.fill: ${priv}
|
||||
style.stroke: ${s-priv}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
|
||||
junc: JUNC-1 {
|
||||
shape: diamond
|
||||
style.fill: ${priv}; style.stroke: ${s-priv}; style.font-color: ${txt}
|
||||
}
|
||||
|
||||
terminal.hatch_t -> maint: "z=1 down z=0" {
|
||||
style.stroke: ${s-priv}; style.stroke-dash: 5
|
||||
}
|
||||
maint -> junc { style.stroke: ${s-priv}; style.stroke-dash: 5 }
|
||||
junc -> bar.hatch_b: "z=0 up z=1" {
|
||||
style.stroke: ${s-priv}; style.stroke-dash: 5
|
||||
}
|
||||
|
||||
# ── SOUTH-EAST ENTRY: TRAM ──
|
||||
|
||||
the_loop: The Loop\n(station tram · 6 districts) {
|
||||
shape: hexagon
|
||||
style.fill: ${neut}
|
||||
style.stroke: ${s-neut}
|
||||
style.font-color: ${txt}
|
||||
}
|
||||
|
||||
platform: Transit Platform\n~12×8 vt · bar-side\n(encounter node) {
|
||||
style.fill: ${pub}
|
||||
style.stroke: ${s-pub}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
the_loop -> platform: "workers arrive here (G-11)" {
|
||||
style.stroke: ${s-neut}
|
||||
}
|
||||
platform -> bar.approach: "adjacent" {
|
||||
style.stroke: ${s-pub}
|
||||
}
|
||||
|
||||
# ── SECTOR 3 ──
|
||||
|
||||
sector3: Sector 3 Residential\n~15×12 vt · Drin/Naia {
|
||||
style.fill: ${spub}
|
||||
style.stroke: ${s-spub}
|
||||
style.font-color: ${txt}
|
||||
style.border-radius: 4
|
||||
}
|
||||
|
||||
sector3 -> terminal.forecourt: "near Terminal" {
|
||||
style.stroke: ${s-spub}; style.stroke-dash: 3
|
||||
}
|
||||
sector3 -> maint: "adjacent to spine" {
|
||||
style.stroke: ${s-neut}; style.stroke-dash: 3
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1006 KiB |
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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.*
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
@@ -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.*
|
||||
@@ -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.**
|
||||
@@ -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.**
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user