Merge remote-tracking branch 'origin/main' into copy

This commit is contained in:
2026-02-15 23:46:00 +01:00
84 changed files with 12018 additions and 443 deletions
+56 -1
View File
@@ -2,5 +2,60 @@
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"teammateMode": "in-process"
"teammateMode": "in-process",
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git push *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git pull *)",
"Bash(git status *)",
"Bash(git log *)",
"Bash(git diff *)",
"Bash(git show *)",
"Bash(git checkout *)",
"Bash(git stash *)",
"Bash(git branch *)",
"Bash(git worktree *)",
"Bash(git config *)",
"Bash(git mv *)",
"Bash(git rm *)",
"Bash(git ls-tree *)",
"Bash(git rev-parse --show-toplevel)",
"Bash(db/connectors/ticket *)",
"Bash(db/connectors/sprint *)",
"Bash(db/connectors/sqlite-query *)",
"Bash(db/connectors/sqlite-exec *)",
"Bash(db/connectors/qdrant-search *)",
"Bash(db/connectors/qdrant-index *)",
"Bash(db/connectors/qdrant-health)",
"Bash(db/connectors/qdrant-count)",
"Bash(db/connectors/sqlite-init)",
"Bash(db/connectors/decisions-sync)",
"Bash(make *)",
"Bash(make)",
"Bash(tea *)",
"Bash(chmod *)",
"Bash(ls *)",
"Bash(find *)",
"Bash(list *)",
"Bash(tree *)",
"Skill(commit)",
"Skill(worktree-update)",
"Skill(start-sprint)"
],
"deny": [
"Bash(git push --force *)",
"Bash(git reset --hard *)",
"Bash(git clean -f *)",
"Bash(rm -rf *)"
]
}
}
+36 -33
View File
@@ -53,34 +53,24 @@ file so the team knows who to spawn.
## Workflow
### 1. Determine sprint number
### 1. Run sprint prepare
Get carry-overs, backlog candidates, and decision gaps in one shot:
```bash
db/connectors/ticket sprint --active
db/connectors/sprint prepare
```
Next sprint = active sprint ID + 1. If no active sprint, ask the user.
This auto-detects the next sprint number (max ID + 1), creates the sprint
record in `planning` status if needed, and outputs:
- Previous sprint status and carry-over candidates
- Backlog candidates grouped by team
- Decision coverage gaps
- Already-assigned tickets (if any)
### 2. Gather current sprint state
### 2. Deepen the scan
Review the active sprint for carry-overs:
```bash
db/connectors/ticket list --sprint <current_id> --status in_progress
db/connectors/ticket list --sprint <current_id> --status ready
```
Any ticket not `done` is a potential carry-over. Note these for the briefing.
### 3. Scan the backlog
Pull candidate tickets by priority:
```bash
db/connectors/ticket epics --status backlog
```
For critical epics, check their children:
For critical epics, check their children for granular candidates:
```bash
db/connectors/ticket children <epic_id>
@@ -88,7 +78,7 @@ db/connectors/ticket children <epic_id>
Use `db/connectors/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
### 4. Read existing code state
### 3. Read existing code state
Scan what's already built to write accurate "what exists" notes:
@@ -103,7 +93,7 @@ ls client/scripts/ client/scripts/*/
Read key files that sprint tickets will build on (bridge types, existing
renderers, etc.) to reference specific integration points in the briefing.
### 5. Select tickets — propose to user
### 4. Select tickets — propose to user
Based on the backlog scan, propose a sprint with:
@@ -122,22 +112,30 @@ Selection heuristics:
- Aim for 3-6 tickets per team, with parallel tracks where possible
- Check `decisions/questions.md` for open Q-NNN items that block candidates
### 6. Read relevant decisions
### 5. Read relevant decisions
For the selected tickets, identify which `decisions/*.md` files are relevant.
Read them to provide accurate cross-references in the briefing.
### 7. Write briefing files
### 6. Write briefing files
Create `docs/sprints/sprint-N/` and write one file per team.
Read the template at `references/briefing-template.md` in this skill directory
for the exact file structure.
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
Each team branch is checked out in its own worktree, and each worktree
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
paths in briefings must be relative to the worktree/git root. Example:
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
paths that navigate outside the git root (`../sibling-worktree/...`).
Agents must stay within the git root they are running in.
Key requirements per file:
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
referencing existing Rust modules by path
- **client.md**: Same structure, notes referencing existing GDScript files by path
referencing existing Rust modules by path from worktree root (e.g. `server/src/simulation/movement.rs`)
- **client.md**: Same structure, notes referencing existing GDScript files by path from worktree root (e.g. `client/scripts/rendering/fog.gd`)
- **copy.md**: In-game text tasks — dialogue, UI copy, tooltips, flavor text, lore
- **audio.md**: Sound design, music, audio integration tasks
- **visual.md**: Art direction, asset creation, visual consistency tasks
@@ -148,19 +146,24 @@ Key requirements per file:
Only generate briefing files for teams that have tickets assigned in the sprint.
Not every sprint will have work for every team.
### 8. Assign tickets to sprint in DB
### 7. Assign tickets to sprint in DB
After the user approves, assign all selected tickets to the new sprint:
After the user approves, assign all selected tickets. The sprint record
was already created by `sprint prepare` in step 1 (status: `planning`).
Update it with the theme and goal, then assign tickets:
```bash
# Create the sprint
db/connectors/sqlite-exec "INSERT INTO sprints (name, goal, status) VALUES ('Sprint N: Theme', 'goal', 'planned')"
# Update the sprint with theme and goal
db/connectors/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>
```
### 9. Present summary
The sprint stays in `planning` status until explicitly activated via
`db/connectors/sprint start`. This prevents starting an unplanned sprint.
### 8. Present summary
Output:
- Sprint number, theme, and goal
+13 -13
View File
@@ -35,34 +35,34 @@ git merge origin/main --no-edit
If the merge has conflicts, report them and stop — do not force-resolve.
### 3. Find the active sprint
### 3. Load sprint context
Run the sprint CLI to get the full context dump in one shot:
```bash
db/connectors/ticket sprint --active
db/connectors/sprint start-work
```
Extract the sprint ID and name from the JSON output. If no active sprint,
report that and stop.
This auto-detects the active sprint and current team from the branch.
It outputs: sprint metadata, briefing paths, decision refs, actionable
tickets, blocked tickets, and done tickets.
If no active sprint is found, report that and stop.
### 4. Read the sprint briefing
Read `docs/sprints/sprint-N/<team>.md` where N is the sprint ID
and team matches the branch name (e.g. `server.md`, `client.md`, `copy.md`).
If no matching briefing exists for the team, report that and suggest running
Read the briefing file(s) listed in the `start-work` output
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
If no matching briefing exists for the team, suggest running
`/plan-sprint` to generate one.
Also check for a `joint.md` briefing — joint tasks involve both teams and
should be mentioned.
### 5. Load ticket details
For each ticket listed in the briefing, run:
For tickets that need more detail than the `start-work` summary provides:
```bash
db/connectors/ticket show <id>
```
Identify which tickets are actionable now (no open blockers) vs blocked.
### 6. Read key decisions
Read the decision files referenced in the sprint briefing so the agent has
+112
View File
@@ -0,0 +1,112 @@
---
name: start-workshop
description: >
Start a multi-agent design workshop from a workshop brief. Use when the user says
"start workshop", "run workshop", "let's start the workshop", or invokes /start-workshop.
Parses the workshop brief to extract participants, questions, and round format.
Creates a team, tasks, and spawns agents as teammates via the Task tool.
---
# Start Workshop
## Prerequisites
A workshop brief must exist at `docs/workshops/{name}/{name}-workshop-brief.md` containing:
- **Participants** line (comma-separated agent names)
- **Questions for participants** sections with numbered questions tagged by agent name
- **Workshop Format** section defining number of rounds and their purpose
## Workflow
### 1. Parse the Brief
Read the workshop brief. Extract:
- Workshop name (from directory name)
- Participant list (from `**Participants:**` line)
- Per-participant questions (scan for `**{AgentName}**:` patterns in questions sections)
- Round count and round descriptions (from `**Workshop Format**` section)
### 2. Create Team
```
TeamCreate: team_name = "{workshop-name}", description from brief title
```
### 3. Always-Present Agents
Two agents join every workshop regardless of the participant list:
| Agent | Role | Task | Participates in discussion? |
|-------|------|------|-----------------------------|
| **Qatux** | Documenter | Captures all decisions, questions, dissent, consensus. Writes `workshop-notes.md` per round, produces final `workshop-outcomes.md`. | No — observes and records only |
| **SI** | Sprint prep | Suggest adding when outputs include tickets. Creates tickets from decisions, links to sprint backlog. | No — execution prep only |
Qatux and SI are **never dismissed early.** If the user reduces the team mid-workshop, keep qatux and si (if added). Documenting everything prevents loss of valuable information.
### 4. Create Tasks (Round 1)
One task per participant containing:
- Instruction to read the full brief at its path
- The specific questions assigned to that participant (extracted from all layers)
- The output format from the brief's round description
One task for Qatux: "Document Round 1 — read all agent responses and capture decisions, questions, and dissent."
Assign all tasks using TaskUpdate with `owner` = agent name.
### 5. Spawn Agents
Use the Task tool to spawn each agent as a teammate. Each call should:
- Set `team_name` to the workshop team name
- Set `name` to the agent name (e.g., "gestalt")
- Set `subagent_type` to the matching agent type (same as name — see reference table)
- Provide a prompt telling the agent to check TaskList for their assigned task
Spawn all agents in parallel (one Task call per agent in a single message). Agents will appear as teammates in the Claude Code UI and pick up their tasks from the shared task list.
For large workshops (>6 agents), spawn participants in batches to avoid overwhelming the system. Always-present agents (Qatux, SI) can run in background via `run_in_background: true`.
### 6. Monitor
- TaskList to check progress
- SendMessage to nudge idle agents or provide clarification
- Agents work autonomously — claim tasks, read the brief, produce responses
### 7. Between Rounds
When all Round N tasks are complete:
1. Qatux produces round summary in `workshop-notes.md`
2. Create Round N+1 tasks (integration pass, synthesis, etc.)
3. Assign to agents with TaskUpdate
4. Agents continue working
### 8. Wrap Up
**Always ask the user before wrapping up.** There may be more to discuss or additional rounds needed. Only proceed to wrap-up when the user confirms.
Wrap-up sequence:
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
2. If SI is present, SI creates tickets from decided items
3. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
4. TeamDelete to clean up
## Agent Type Reference
Agent name maps directly to subagent_type:
| Name | Domain | Typical workshop role |
|------|--------|-----------------------|
| gestalt | Systems design | Mechanics, fun factor, system interactions |
| ozzie | Player experience | Wow moments, feel, emotional response |
| tyre | Technical architect | Feasibility, performance, architecture |
| stig | UI developer | Interface patterns, layout, interaction flows |
| dudley | Server developer | Server-side systems, data flow, ECS |
| paula | Narrative | Story, dialogue, character, political depth |
| araminta | Visual design | Art direction, visual treatment, aesthetics |
| nigel | Replayability | Emergent stories, second-playthrough, sandbox |
| gore | Themes/endgame | What the game is about, philosophical questions |
| mellanie | Copywriter | In-game text, voice, tone |
| miri | Worldbuilder | Setting, factions, lore, consistency |
| inigo | Sound design | Audio, soundscape, spatial audio |
| troblum | Tech consultant | Second opinion on architecture/tech choices |
| hoshe | QA/testing | Test plans, verification, quality |
+3
View File
@@ -53,6 +53,9 @@ db/connectors/ticket sprint [--active]
db/connectors/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`
### Dependencies
```bash
db/connectors/ticket deps <id>
+7 -1
View File
@@ -44,10 +44,16 @@ Branch determines the mode: `main` → outbound sync, anything else → inbound
git fetch --all
```
Discover all worktree branches (excluding `main` itself):
```bash
git worktree list | grep -v '\[main\]' | sed 's/.*\[//;s/\]//'
```
For each worktree branch, check if it has commits ahead of main:
```bash
git rev-list --count main..<branch>
git rev-list --count main..origin/<branch>
```
Skip branches with 0 commits ahead. For branches that ARE ahead, collect:
+56
View File
@@ -7,6 +7,62 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Client protocol v6 bridge — decode player_stance (4 variants) and player_inventory from ObserverSnapshot, TOGGLE_STANCE_UP/DOWN input actions, 25 gdUnit4 tests
- Three-scope z-layer rendering pipeline (D-049) — world z:0-900 inside CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal CanvasLayer 30. Y-sort contract enforced, reserved VFX/airborne/lower-floor ranges documented
- Cursor state machine (#429, D-056) — 4 states (Default/EntityHover/ObjectHover/WeaponAim), 150ms transitions, insert-styled geometric shapes
- Fog shader rebuild (#430, D-059) — 5-layer fragment shader with animated Perlin noise, CanvasGroup compositing, FogState autoload for visibility/exploration textures
- Entity interaction list (#432, D-057) — vertical multi-verb menu, insert-styled, sprint suppression, diegetic toggle
- World radial menu (#433, D-058) — 2 spokes (Observe + Insert), drag-release and click-click input, 60-degree acceptance zones
- Inventory UI (#438, D-065) — 3x3 grid, 40x40px slots, 1-9 hotkey selection
- Stance indicator (#439, D-053) — color-coded HUD text, C/X keybinds
- Architecture docs: z-layer gap analysis, fog shader spec, flying taxi feasibility analysis
### Changed
- Scene tree restructured: Entities z_index 3->0 (critical y-sort fix), FloorObjects->10, YSortGroup->100, Overhead->300, FogOverlay->900, ModalLayer added
- constants.gd rewritten with three-scope z numbering and full reserved range documentation
- Fog renderer replaced: TileMapLayer-based fog_renderer.gd deleted, replaced by shader-based fog_shader.gd + fog.gdshader
### Added
- ObserverSnapshot v6 wire protocol (#449) — player_stance (MovementStance) and player_inventory (Vec\<InventoryItem\>) fields with serde defaults for backward compatibility
- Stance system (#417) — Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers (40%/100%/150%/100%), PlayerMoveCooldown component, ToggleStanceUp/Down player actions
- TilePresence posture layers (#420) — Standing/Prone/Seated/Fixture occupancy layers enabling same-tile coexistence (e.g. seated NPC + standing player), layer-based collision in validate_movement
- ObjectType component (#421) — Readable/Container/Terminal/Door/Pickup/Furniture types with Phase 1 verb sets computed from type + proximity range
- Phase 2 verb filter (#422) — KG-gated observer-side verb processing: POI priority flips (D-060), Confront injection at KnowsDetails+ confidence, contradiction marking, archetype-specific label relabeling (Smuggler sees Move/Stash, Detective sees Scan/Flag on containers)
- CharacterArchetype component — Smuggler/Detective archetype for Phase 2 verb label differentiation (D-057)
- VerbKind::Confront — Phase 2 only verb injected when observer has KnowsDetails+ on an NPC at close range
- Smuggler inventory system (#424) — CarriedBy(StableId) component, Take/Place verbs, 9-slot (3x3 grid) capacity, auto-slot assignment, info boundary enforcement (carried items invisible to other observers)
- MovementProfile component (#418) — per-archetype default stance (smuggler=Walk, detective=Walk), applied on spawn, factory methods for future archetypes
- Sprint interaction buffer suppression (#419, D-055) — sprint stance explicitly clears interaction buffer, no verbs computed or sent during sprint, anomaly monologue pipeline unaffected
- Sprint anomaly double-take monologue (#428, D-055) — SprintAnomalyQueue component detects Contradicted entities during sprint, fires delayed retroactive monologue after ~1.5s ("Wait — something wasn't right back there"), first-in-wins queue semantics, 3 hardcoded v0.1 lines
### Changed
- Protocol version bumped from 5 to 6 (stance, inventory, ObjectType, verb system fields)
- MessagePack fixtures regenerated for protocol v6
- Input processing queries expanded for stance and cooldown components with backward-compatible Option wrapping
- Observer pipeline queries expanded for Stance and CharacterArchetype components
- NearbyInteraction carries object_type and contradicted fields for Phase 2 context
- BridgePlugin system ordering: process_sprint_anomaly_monologue runs after trigger_monologue, compute_observer_snapshot runs after anomaly processing
- Player spawn includes MovementProfile, Stance, PlayerMoveCooldown, and SprintAnomalyQueue components
- 331 tests total (131 new) — comprehensive QA coverage across stance, occupancy, Phase 2 verbs, sprint suppression, inventory, anomaly monologue, and wire format
### Added
- D-066: Dual-scale grid — 0.5m simulation tiles for stealth granularity, 1m visual tiles for proportional art (2x retina factor). All world geometry 2x2 sim tile minimum so cover/LOS maps 1:1 with visuals. Amends OQ-01.
- Sprint CLI (`db/connectors/sprint`) — unified sprint lifecycle management with 5 subcommands: status, start, stop, start-work, prepare. Auto-detects sprint from DB state and team from git branch. Guards prevent activating unplanned sprints.
- Shared permission settings in `.claude/settings.json` — git, ticket/sprint CLI, make, tea, and core skills pre-approved across all worktrees. Deny rules block destructive operations.
- Decisions D-053 through D-065 from Control & Interaction Workshop — formalized interaction verb system, contextual actions, NPC awareness model, and related design decisions
- Sprint 6 Touch briefings for server, client, copy, and joint teams
- Control & Interaction Workshop outputs — full workshop notes and outcomes
- Smuggler inventory item specs for transit district (#441)
- Start-workshop skill for multi-agent design workshops
### Fixed
- Added worktree boundary rules to CLAUDE.md — agents must stay within the git root, no navigating to sibling worktrees or above the repo
- Plan-sprint skill now enforces worktree-relative paths in generated briefings
- Worktree-update skill now discovers branches dynamically via `git worktree list` instead of relying on hardcoded branch names — fixes missed branches like `planning`
### Changed
- Start-sprint and plan-sprint skills updated to use sprint CLI instead of manual multi-query workflows
- Permission syntax migrated from deprecated `:*` suffix to modern space-wildcard format across all worktrees
- Internal monologue trigger system (#414) — enter_location fires on first tick, time_idle fires after 100 ticks of no movement, 300-tick cooldown, dedup within session, random line selection from content pools via ChaCha20 RNG
- MonologueEvent in ObserverSnapshot v5 — current_monologue field carries id, text, and display duration across the IPC bridge
- Client monologue display wiring — protocol v5 decoding, GameState extraction, HUD display pass-through
+28 -4
View File
@@ -33,10 +33,10 @@ db/
skills/ # Skill definitions
decisions/ # Decision domain files (source of truth)
README.md # Domain index and query examples
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031
perception.md # D-011, D-015, D-016, D-017, D-018, D-019
content.md # D-023, D-024, D-025, D-028, D-029
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027
architecture.md # 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
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
scope.md # 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
process.md # D-004, D-021, D-022
questions.md # Q-001 through Q-011
rejected.md # R-001 through R-010
@@ -50,6 +50,18 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
## Agent Instructions
### Worktree boundaries
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
- Do not navigate above the git root unless explicitly instructed.
### Database
The ticketing database (`settledreach.db`) lives in the **parent directory** shared across all worktrees — it is not tracked in git. A backup is committed to `docs/backups/settledreach.db.backup` via main only.
@@ -68,6 +80,18 @@ db/connectors/ticket show 78
db/connectors/ticket sprint --active
```
### Sprint CLI
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
```bash
db/connectors/sprint status # Current sprint progress
db/connectors/sprint status --team server # Team-scoped view
db/connectors/sprint start-work --team client # Full context dump for starting work
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
db/connectors/sprint start # Activate a planned sprint
db/connectors/sprint stop # Complete an active sprint
```
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
```bash
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
+11
View File
@@ -21,6 +21,7 @@ SimBridge="*res://scripts/autoloads/sim_bridge.gd"
GameState="*res://scripts/autoloads/game_state.gd"
InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
[display]
@@ -94,6 +95,16 @@ pause={
"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":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
stance_up={
"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":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
]
}
stance_down={
"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":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
]
}
[rendering]
+99 -7
View File
@@ -1,36 +1,114 @@
[gd_scene load_steps=10 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=15 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
[ext_resource type="Script" path="res://scripts/rendering/entity_renderer.gd" id="3_entity"]
[ext_resource type="Script" path="res://scripts/rendering/fog_renderer.gd" id="4_fog"]
[ext_resource type="Script" path="res://scripts/rendering/fog_shader.gd" id="4_fog"]
[ext_resource type="Script" path="res://scripts/rendering/tile_renderer.gd" id="5_tile"]
[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"]
[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"]
[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"]
[ext_resource type="PackedScene" path="res://ui/interaction_prompt.tscn" id="9_prompt"]
[ext_resource type="Script" path="res://scripts/rendering/cursor_renderer.gd" id="10_cursor"]
[ext_resource type="PackedScene" path="res://ui/interaction_list.tscn" id="11_ilist"]
[ext_resource type="PackedScene" path="res://ui/inventory_grid.tscn" id="12_inv"]
[ext_resource type="PackedScene" path="res://ui/stance_indicator.tscn" id="13_stance"]
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
; D-049 Z-level rendering pipeline (z-layer-gap-analysis.md)
;
; World scope (z:-200 to z:900, inside FogGroup CanvasGroup):
; z:0 FloorTiles — ground plane
; z:10 FloorObjects — cosmetic floor detail, ground shadows
; z:100 YSortGroup — y-sorted: furniture, entities, wall faces (all z:0 relative)
; z:200 [future] — airborne (projectiles, low-flying objects)
; z:300 Overhead — ceiling edges, semi-transparent occlusion
; z:350 [future] — high airborne (above ceiling, scale > 1.0)
; z:400 [future] — upper floor content (rare with fixed camera)
; z:900 FogOverlay — OUTSIDE FogGroup, fog shader
;
; Y-sort contract: ALL children of YSortGroup that participate in positional
; occlusion MUST have z_index = 0. z_index is PRIMARY sort, y is SECONDARY.
; Entities node is AFTER Furniture node — D-044 entity-wins-ties on same y.
[node name="World" type="Node2D" parent="."]
script = ExtResource("2_world")
[node name="TileMapLayer" type="TileMapLayer" parent="World"]
; --- World content inside CanvasGroup for fog compositing ---
; CanvasGroup captures everything beneath it into one texture.
; FogOverlay (outside) draws the fog shader over this composited texture.
[node name="FogGroup" type="CanvasGroup" parent="World"]
; z:0 — Floor tiles: zone identity, movement surface
[node name="FloorTiles" type="TileMapLayer" parent="World/FogGroup"]
z_index = 0
script = ExtResource("5_tile")
[node name="FogOverlay" type="TileMapLayer" parent="World"]
script = ExtResource("4_fog")
; z:10 — Floor objects: cosmetic detail, walked over, ground shadows from airborne
; (placeholder — populated when floor object art is added)
[node name="FloorObjects" type="Node2D" parent="World/FogGroup"]
z_index = 10
[node name="Entities" type="Node2D" parent="World"]
; z:100 — Y-sorted group: furniture + entities + wall faces interleave by y-position
; ALL children MUST use z_index = 0 (y-sort contract, Godot #62715)
[node name="YSortGroup" type="Node2D" parent="World/FogGroup"]
y_sort_enabled = true
z_index = 100
; Furniture (z:0 relative) — tables, chairs, placed objects
; Placeholder — before Entities in tree order so entities win visual ties (D-044)
[node name="Furniture" type="Node2D" parent="World/FogGroup/YSortGroup"]
y_sort_enabled = true
z_index = 0
; Entities (z:0 relative) — D-033 colored sprites, y-sorted with furniture
; MUST be z_index = 0 for correct y-sort interleaving
[node name="Entities" type="Node2D" parent="World/FogGroup/YSortGroup"]
y_sort_enabled = true
z_index = 0
script = ExtResource("3_entity")
; z:300 — Overhead: ceiling edges, upper floor structure, semi-transparent occlusion
; (placeholder — populated when overhead art is added)
[node name="Overhead" type="Node2D" parent="World/FogGroup"]
z_index = 300
; --- z:900 — Fog of perception (D-059 shader-based) ---
; OUTSIDE FogGroup. ColorRect child with fragment shader composites
; 5-layer fog over the world. fog_shader.gd manages uniforms.
[node name="FogOverlay" type="Node2D" parent="World"]
z_index = 900
script = ExtResource("4_fog")
; --- Camera ---
[node name="Camera2D" type="Camera2D" parent="."]
position_smoothing_enabled = true
position_smoothing_speed = 6.0
zoom = Vector2(2, 2)
; --- Insert overlay (CanvasLayer 10) ---
; Bloom-rendered, NOT affected by fog or camera transform.
; World-anchored elements convert world→screen coords in scripts.
[node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
; D-057: Entity interaction vertical list — multi-verb, insert-styled
[node name="InteractionList" parent="InsertOverlay" instance=ExtResource("11_ilist")]
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" parent="InsertOverlay" instance=ExtResource("14_radial")]
; --- UI layer (CanvasLayer 20) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."]
layer = 20
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
@@ -38,4 +116,18 @@ zoom = Vector2(2, 2)
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
[node name="InteractionPrompt" parent="UILayer" instance=ExtResource("9_prompt")]
; D-053: Stance indicator — top-right, color-coded
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen.
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
[node name="ModalLayer" type="CanvasLayer" parent="."]
layer = 30
+118
View File
@@ -0,0 +1,118 @@
extends Node
## Fog texture state — visibility/exploration/zone tint images updated from GameState.
## Read by fog_shader.gd for shader uniforms. Not a renderer — pure data.
## Architecture: docs/architecture/fog-shader-spec.md | D-059
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
var zone_tint_texture: ImageTexture
var _vis_bytes: PackedByteArray
var _exp_bytes: PackedByteArray
var _vis_image: Image
var _exp_image: Image
var _tint_image: Image
var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
func _ready() -> void:
_resize(Rect2i(0, 0, 64, 64))
func _resize(bounds: Rect2i) -> void:
map_bounds = bounds
_width = maxi(bounds.size.x, 1)
_height = maxi(bounds.size.y, 1)
var sz := _width * _height
_vis_bytes = PackedByteArray()
_vis_bytes.resize(sz)
_vis_bytes.fill(0)
_vis_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture = ImageTexture.create_from_image(_vis_image)
_exp_bytes = PackedByteArray()
_exp_bytes.resize(sz)
_exp_bytes.fill(0)
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image)
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
_tint_image.fill(Color(0.05, 0.05, 0.08))
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
func update_from_state() -> void:
# Resize if map bounds changed
var tiles := GameState.visible_tiles
if tiles.size() > 0:
var new_bounds := _compute_bounds(tiles)
if new_bounds != map_bounds:
_resize(new_bounds)
var ox: int = map_bounds.position.x
var oy: int = map_bounds.position.y
var positions: Dictionary = GameState.visible_positions
var sectors: Dictionary = GameState.visibility_sectors
# TODO(v0.2): gradual decay over game-time instead of immediate 255→128
# 1. Clear visibility, then write current LOS
_vis_bytes.fill(0)
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
var sector: String = sectors.get(pos, "Forward")
_vis_bytes[py * _width + px] = 255 if sector == "Forward" else 180
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture.update(_vis_image)
# 2. Exploration: tiles leaving LOS decay to 128, visible tiles stay 255
# Only touch tiles that changed (O(visible) not O(map_size))
for pos in _prev_visible:
if not positions.has(pos):
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
var idx: int = py * _width + px
if _exp_bytes[idx] > 128:
_exp_bytes[idx] = 128
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
_exp_bytes[py * _width + px] = 255
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image)
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
_prev_visible = positions.duplicate()
func _compute_bounds(tiles: Array) -> Rect2i:
var min_x := 999999
var min_y := 999999
var max_x := -999999
var max_y := -999999
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
min_x = mini(min_x, int(tile.x))
min_y = mini(min_y, int(tile.y))
max_x = maxi(max_x, int(tile.x))
max_y = maxi(max_y, int(tile.y))
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
if min_x > max_x:
return Rect2i(0, 0, 1, 1)
# Margin for fog gradient bleed at edges
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
+14
View File
@@ -25,6 +25,10 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs
# v5 fields (#414)
var current_monologue: Variant = null # {id, text, duration_seconds} or null
# v6 fields (#449, D-053, D-065)
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
var player_inventory: Array = [] # [{item_id, name, slot}]
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -82,6 +86,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
current_monologue = null
# v6: player_stance (#449, D-053)
if snapshot.has("player_stance") and snapshot.player_stance is String:
player_stance = snapshot.player_stance
# v6: player_inventory (#449, D-065)
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
player_inventory = snapshot.player_inventory
else:
player_inventory = []
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+6 -1
View File
@@ -6,7 +6,8 @@ extends Node
enum Action {
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
}
var input_queue: Array[Dictionary] = []
@@ -40,6 +41,10 @@ func _unhandled_input(event: InputEvent) -> void:
action = Action.OPEN_MENU
elif event.is_action_pressed("pause"):
action = Action.PAUSE
elif event.is_action_pressed("stance_up"):
action = Action.TOGGLE_STANCE_UP
elif event.is_action_pressed("stance_down"):
action = Action.TOGGLE_STANCE_DOWN
# Queue the action if valid
if action != -1:
+4
View File
@@ -242,6 +242,8 @@ static func _action_enum_to_wire(action: int) -> String:
InputMapper.Action.INTERACT: return "Interact"
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
InputMapper.Action.PAUSE: return "Pause"
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
InputMapper.Action.OPEN_MENU:
# Client-only action, not part of wire protocol
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
@@ -328,6 +330,8 @@ func _test_snapshot() -> Dictionary:
"tick_rate": "Full",
},
"player_facing": _test_facing,
"player_stance": "Walk",
"player_inventory": [],
"entities": entities,
"tiles": _test_tiles(),
"visible_tiles": _test_visible_tiles(),
@@ -0,0 +1 @@
uid://xm2hswjm3pss
+51
View File
@@ -5,6 +5,48 @@ class_name Constants
## Tile size in pixels — all renderers and coordinate conversions use this.
const TILE_SIZE: int = 32
# D-049 Z-level rendering pipeline — three-scope architecture.
# Full spec: docs/architecture/z-layer-gap-analysis.md
#
# WORLD SCOPE (z:-200 to z:900, inside FogGroup CanvasGroup)
# All world content composited into one texture, then fog drawn over it.
# Y-sort contract: all children of YSortGroup MUST use z_index = 0.
# z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715).
const Z_FLOOR: int = 0 # Floor tiles — ground plane
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
# z:20-99 reserved: liquid surface (z:110), surface effects
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
# z:110 reserved: liquid surface occlusion (alpha by depth)
# z:150-199 reserved: ground VFX (smoke origins, gas pools, sparks)
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
# z:250-299 reserved: mid-air VFX (rising smoke, floating particles)
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
# z:325-349 reserved: ceiling VFX (smoke through ceiling)
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
# z:500-899 reserved: edge cases
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
# Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101)
# z:-75 to z:-51 reserved: lower floor VFX
#
# INSERT SCOPE (CanvasLayer 10)
# Bloom-rendered, not affected by fog or camera transform.
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
#
# UI SCOPE (CanvasLayer 20)
# HUD, monologue, cursor — always visible.
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
#
# MODAL SCOPE (CanvasLayer 30)
# Full-screen overlays: pause, inventory modal, death screen.
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
#
# Rendering ceiling: 10 floors (25m) above current floor.
# Above this: no sprites, ground shadows + environmental effects only.
const RENDER_CEILING_FLOORS: int = 10
# Visible floor window (looking down): current - 2 floors (detailed + parallax).
const VISIBLE_FLOOR_DEPTH: int = 2
# D-033: Entity relationship color palette
# Color represents the player's RELATIONSHIP to the entity, not an objective property.
# Phase 1: default colors mapped by entity kind (Player/Npc/Object/Terrain).
@@ -16,6 +58,15 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player": return ENTITY_COLOR_PLAYER
"Npc": return ENTITY_COLOR_UNKNOWN
"Object", "Terrain": return ENTITY_COLOR_OBJECT
_: return ENTITY_COLOR_OBJECT
# D-015: Peripheral vision dimming
const PERIPHERAL_ALPHA: float = 0.5
+31 -4
View File
@@ -4,7 +4,12 @@ extends Node2D
@onready var camera = $Camera2D
@onready var hud = $UILayer/HUD
@onready var monologue_display = $UILayer/MonologueDisplay
@onready var interaction_prompt = $UILayer/InteractionPrompt
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
@onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -22,11 +27,25 @@ func _process(_delta: float) -> void:
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# D-057: Update interaction list from game state
if interaction_list and interaction_list.has_method("update_from_state"):
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()
# Show monologue if server sent one this tick (#414)
# Consume-once: set to null after showing to prevent re-display.
# Single monologue per snapshot is guaranteed by server.
if GameState.current_monologue != null and monologue_display:
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
GameState.current_monologue = null # Consume — don't re-show next frame
GameState.current_monologue = null
# Track camera to player position every frame (D-015: locked, no panning)
# Camera2D smoothing handles interpolation — we just set the target
@@ -36,12 +55,20 @@ func _process(_delta: float) -> void:
var inputs = InputMapper.flush_queue()
for input in inputs:
if input.action == InputMapper.Action.INTERACT:
var target_id: int = interaction_prompt.get_interaction_target()
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
var verb: String = ""
if interaction_list and interaction_list.has_method("get_interaction_target"):
target_id = interaction_list.get_interaction_target()
verb = interaction_list.get_selected_verb()
if target_id < 0 and interaction_prompt:
target_id = interaction_prompt.get_interaction_target()
verb = interaction_prompt.get_selected_verb()
# Always send struct form for Interact (#415) — server expects named fields
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
"verb": interaction_prompt.get_selected_verb(),
"verb": verb,
}
else:
input["action_data"] = {
+21 -1
View File
@@ -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 = 5
const PROTOCOL_VERSION: int = 6
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -104,6 +104,24 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"duration_seconds": float(raw_monologue.get("duration_seconds", 5.0)),
}
# v6: player_stance (#449, D-053) — unit enum → bare string
var player_stance: String = "Walk"
var raw_stance: Variant = raw.get("player_stance")
if raw_stance is String:
player_stance = raw_stance
# v6: player_inventory (#449, D-065) — array of {item_id, name, slot}
var player_inventory: Array = []
var raw_inventory: Variant = raw.get("player_inventory")
if raw_inventory is Array:
for raw_item in raw_inventory:
if raw_item is Dictionary and raw_item.has("item_id") and raw_item.has("name"):
player_inventory.append({
"item_id": int(raw_item["item_id"]),
"name": str(raw_item["name"]),
"slot": int(raw_item.get("slot", 0)),
})
return {
"tick": tick,
"entities": entities,
@@ -111,6 +129,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"version": version,
"game_time": game_time,
"player_facing": player_facing,
"player_stance": player_stance,
"player_inventory": player_inventory,
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
+312
View File
@@ -0,0 +1,312 @@
class_name CursorRenderer
extends Node2D
## Cursor state machine — 4 geometric states, 150ms linear transitions (D-056).
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
## Detects entity hover via world-space proximity to visible entities.
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
# --- Public ---
var current_state: State = State.DEFAULT
var hovered_entity_id: int = -1
var weapon_mode_active: bool = false
signal state_changed(new_state: State)
signal hovered_entity_changed(entity_id: int)
# D-056 colors
const COLOR_DEFAULT := Color("#c8d0e0")
const COLOR_OBJECT := Color("#8b8ba0")
const COLOR_OBJECT_FLAGGED := Color("#e8c547")
const COLOR_WEAPON := Color("#f0e8d8")
const TRANSITION_SEC := 0.15 # 150ms linear (D-056)
const HOVER_RADIUS_PX := 16.0 # World pixels — ~half a tile
# Bracket geometry (screen pixels — sized for 24px entity at 2x zoom)
const BRACKET_HALF := 26.0
const BRACKET_ARM := 8.0
# --- Transition state ---
var _target: State = State.DEFAULT
var _t: float = 1.0
var _from: Dictionary = {}
var _time: float = 0.0
var _mouse_inside: bool = true
var _shift_held: bool = false
# Hover tracking
var _hover_color: Color = COLOR_DEFAULT
var _hover_offset: Vector2 = Vector2.ZERO
# Interpolated draw params
var _gap: float = 4.0
var _len: float = 6.0
var _rot: float = 0.0
var _thick: float = 1.0
var _color: Color = COLOR_DEFAULT
var _bloom: float = 0.4
var _bracket_a: float = 0.0
var _alpha: float = 0.45
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
_from = _snapshot()
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_MOUSE_EXIT:
_mouse_inside = false
visible = false
elif what == NOTIFICATION_WM_MOUSE_ENTER:
_mouse_inside = true
visible = true
func _process(delta: float) -> void:
if not _mouse_inside:
return
_time += delta
position = get_viewport().get_mouse_position()
_detect_hover()
if _t < 1.0:
_t = minf(_t + delta / TRANSITION_SEC, 1.0)
_interpolate()
if _t >= 1.0:
current_state = _target
queue_redraw()
# --- Hover detection (automatic, from GameState.visible_entities) ---
func _detect_hover() -> void:
var prev_id := hovered_entity_id
if weapon_mode_active:
var found := _find_nearest_entity()
hovered_entity_id = found.id
_hover_color = found.color
_hover_offset = found.offset
_set_target(State.WEAPON_AIM)
if hovered_entity_id != prev_id:
hovered_entity_changed.emit(hovered_entity_id)
return
var found := _find_nearest_entity()
hovered_entity_id = found.id
_hover_color = found.color
_hover_offset = found.offset
var new_state := State.DEFAULT
if found.id >= 0:
new_state = State.ENTITY_HOVER if found.kind == "Npc" else State.OBJECT_HOVER
_set_target(new_state)
if hovered_entity_id != prev_id:
hovered_entity_changed.emit(hovered_entity_id)
func _find_nearest_entity() -> Dictionary:
var xform := get_viewport().get_canvas_transform()
var mouse_screen := get_viewport().get_mouse_position()
var mouse_world: Vector2 = xform.affine_inverse() * mouse_screen
var best_dist := INF
var result := { id = -1, kind = "", color = COLOR_DEFAULT, offset = Vector2.ZERO }
for entity in GameState.visible_entities:
if not entity.has("entity_id") or not entity.has("x") or not entity.has("y"):
continue
if entity.entity_id == GameState.player_entity_id:
continue
var center := Vector2(
floorf(entity.x) * Constants.TILE_SIZE + Constants.TILE_SIZE * 0.5,
floorf(entity.y) * Constants.TILE_SIZE + Constants.TILE_SIZE * 0.5
)
var dist := mouse_world.distance_to(center)
if dist < HOVER_RADIUS_PX and dist < best_dist:
best_dist = dist
result.id = entity.entity_id
result.kind = entity.get("kind", {}).get("variant", "")
result.color = Constants.color_for_entity_kind(entity)
result.offset = (xform * center) - mouse_screen
return result
# --- State transitions ---
func _set_target(new_state: State) -> void:
if new_state == _target:
return
_target = new_state
_from = _snapshot()
# D-056: weapon aim is "hard transition" — skip interpolation
if new_state == State.WEAPON_AIM:
_t = 1.0
_apply_params(_params_for(State.WEAPON_AIM))
current_state = State.WEAPON_AIM
else:
_t = 0.0
state_changed.emit(new_state)
func _snapshot() -> Dictionary:
return { gap = _gap, len = _len, rot = _rot, thick = _thick,
color = _color, bloom = _bloom, bracket_a = _bracket_a, alpha = _alpha }
func _params_for(state: State) -> Dictionary:
match state:
State.ENTITY_HOVER:
return { gap = 10.0, len = 6.0, rot = 0.0, thick = 1.0,
color = _hover_color, bloom = 0.5, bracket_a = 1.0, alpha = 1.0 }
State.OBJECT_HOVER:
return { gap = 4.0, len = 6.0, rot = PI / 4.0, thick = 1.0,
color = _hover_color, bloom = 0.3, bracket_a = 0.0, alpha = 0.8 }
State.WEAPON_AIM:
return { gap = 12.0, len = 9.0, rot = 0.0, thick = 2.0,
color = COLOR_WEAPON, bloom = 0.0, bracket_a = 0.0, alpha = 1.0 }
_:
return { gap = 4.0, len = 6.0, rot = 0.0, thick = 1.0,
color = COLOR_DEFAULT, bloom = 0.4, bracket_a = 0.0, alpha = 0.45 }
func _apply_params(p: Dictionary) -> void:
_gap = p.gap; _len = p.len; _rot = p.rot; _thick = p.thick
_color = p.color; _bloom = p.bloom; _bracket_a = p.bracket_a; _alpha = p.alpha
func _interpolate() -> void:
var p := _params_for(_target)
_gap = lerpf(_from.gap, p.gap, _t)
_len = lerpf(_from.len, p.len, _t)
_rot = lerp_angle(_from.rot, p.rot, _t)
_thick = lerpf(_from.thick, p.thick, _t)
_color = _from.color.lerp(p.color, _t)
_bloom = lerpf(_from.bloom, p.bloom, _t)
_bracket_a = lerpf(_from.bracket_a, p.bracket_a, _t)
_alpha = lerpf(_from.alpha, p.alpha, _t)
# --- Drawing ---
func _draw() -> void:
# _hover_offset is computed in _detect_hover() during _process(), not recalculated
# here. This is safe because Camera2D (tree sibling, earlier in scene order) applies
# its smoothing transform before CursorRenderer._process() reads canvas_transform.
# Nothing modifies the canvas transform between _process() and _draw().
# Bloom pass — wider, semi-transparent glow
if _bloom > 0.01:
var bloom_mod := 1.0
# D-056: ~10% bloom pulse during entity hover
if current_state == State.ENTITY_HOVER:
bloom_mod += sin(_time * 4.0) * 0.1
var bloom_a := _alpha * _bloom * 0.6 * bloom_mod
_draw_ticks(Color(_color, bloom_a), _thick + 3.0)
if _bracket_a > 0.01:
_draw_brackets(_hover_offset, Color(_color, _bracket_a * _bloom * 0.4))
# Crisp pass
_draw_ticks(Color(_color, _alpha), _thick)
if _bracket_a > 0.01:
_draw_brackets(_hover_offset, Color(_color, _bracket_a * _alpha))
func _draw_ticks(color: Color, thickness: float) -> void:
var dirs := [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT]
for dir in dirs:
var d := dir.rotated(_rot)
draw_line(d * _gap, d * (_gap + _len), color, thickness, true)
func _draw_brackets(offset: Vector2, color: Color) -> void:
for sx in [-1.0, 1.0]:
for sy in [-1.0, 1.0]:
var corner := offset + Vector2(sx * BRACKET_HALF, sy * BRACKET_HALF)
draw_line(corner, corner + Vector2(-sx * BRACKET_ARM, 0), color, 1.0, true)
draw_line(corner, corner + Vector2(0, -sy * BRACKET_ARM), color, 1.0, true)
# --- Test-friendly API (expected by test_cursor_states.gd) ---
func get_state() -> String:
match current_state:
State.DEFAULT: return "Default"
State.ENTITY_HOVER: return "EntityHover"
State.OBJECT_HOVER: return "ObjectHover"
State.WEAPON_AIM: return "WeaponAim"
_: return "Default"
func set_hover_target(data: Dictionary) -> void:
var kind: String = data.get("kind", "")
# D-056: cursor changes require LOS
if not data.get("in_los", true):
return
hovered_entity_id = data.get("entity_id", -1)
if kind == "Npc":
var rel: String = data.get("relationship", "Unknown")
match rel:
"Friendly": _hover_color = Constants.ENTITY_COLOR_FRIENDLY
"PersonOfInterest": _hover_color = Constants.ENTITY_COLOR_POI
"Hostile": _hover_color = Constants.ENTITY_COLOR_HOSTILE
_: _hover_color = Constants.ENTITY_COLOR_UNKNOWN
_target = State.ENTITY_HOVER
_t = 1.0
_apply_params(_params_for(State.ENTITY_HOVER))
current_state = State.ENTITY_HOVER
elif kind == "Object" or kind == "Terrain":
_hover_color = COLOR_OBJECT_FLAGGED if data.get("flagged", false) else COLOR_OBJECT
_target = State.OBJECT_HOVER
_t = 1.0
_apply_params(_params_for(State.OBJECT_HOVER))
current_state = State.OBJECT_HOVER
func clear_hover_target() -> void:
hovered_entity_id = -1
_hover_color = COLOR_DEFAULT
_target = State.DEFAULT
_t = 1.0
_apply_params(_params_for(State.DEFAULT))
current_state = State.DEFAULT
func set_weapon_mode(active: bool) -> void:
weapon_mode_active = active
if active:
_target = State.WEAPON_AIM
_t = 1.0
_apply_params(_params_for(State.WEAPON_AIM))
current_state = State.WEAPON_AIM
else:
_target = State.DEFAULT
_t = 1.0
_apply_params(_params_for(State.DEFAULT))
current_state = State.DEFAULT
func set_shift_held(held: bool) -> void:
_shift_held = held
func get_transition_duration() -> float:
return TRANSITION_SEC
func get_cursor_color() -> Color:
return _color
func get_z_layer() -> int:
return Constants.CANVAS_UI # D-049/D-056
func should_show_interactions() -> bool:
return not weapon_mode_active or _shift_held
func get_interaction_range() -> int:
return 2 # D-056: ~2 sim tiles
+2 -11
View File
@@ -101,18 +101,9 @@ func _remove_entity_node(entity_id: int) -> void:
entity_node.queue_free()
entity_nodes.erase(entity_id)
# D-033 color by entity kind (Phase 1: defaults by kind, not relationship)
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
static func _color_for_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant:
"Player":
return Constants.ENTITY_COLOR_PLAYER
"Npc":
return Constants.ENTITY_COLOR_UNKNOWN
"Object", "Terrain":
return Constants.ENTITY_COLOR_OBJECT
_:
return Constants.ENTITY_COLOR_OBJECT
return Constants.color_for_entity_kind(entity_data)
# Add a facing direction indicator triangle to the player entity
func _add_facing_indicator(parent_node: Control) -> void:
-88
View File
@@ -1,88 +0,0 @@
class_name FogRenderer
extends TileMapLayer
# Fog renderer — draws fog overlay on non-visible tiles (D-011)
# Three visibility states per tile:
# visible = no fog tile (clear)
# fog-edge = semi-transparent dark overlay (adjacent to visible)
# hidden = opaque black overlay
#
# Atlas layout:
# (0,0) = full fog (opaque black)
# (1,0) = fog edge (semi-transparent)
#
# Note: fog-edge uses 8-directional neighbors for visual smoothness.
# Actual visibility boundaries come from the server's shadowcasting (D-011).
# Fog-returns-over-time (D-011 decay) is tracked in #113, not here.
const TILE_SIZE: int = Constants.TILE_SIZE
var _initialized: bool = false
var _all_tile_positions: Dictionary = {} # Vector2i -> true, all known map tiles
func _ready() -> void:
_setup_tileset()
_initialized = true
print("FogRenderer: Initialized")
func _setup_tileset() -> void:
var ts := TileSet.new()
ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE)
var source := TileSetAtlasSource.new()
var img := Image.create(TILE_SIZE * 2, TILE_SIZE, false, Image.FORMAT_RGBA8)
# Full fog (0,0) — opaque black
img.fill_rect(Rect2i(0, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 1.0))
# Fog edge (1,0) — semi-transparent dark
img.fill_rect(Rect2i(TILE_SIZE, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 0.6))
var tex := ImageTexture.create_from_image(img)
source.texture = tex
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
source.create_tile(Vector2i(0, 0))
source.create_tile(Vector2i(1, 0))
ts.add_source(source)
tile_set = ts
# Register all known tile positions (called when tile data arrives)
func register_tile_positions(tiles: Array) -> void:
_all_tile_positions.clear()
for tile_data in tiles:
if tile_data.has("x") and tile_data.has("y"):
_all_tile_positions[Vector2i(tile_data.x, tile_data.y)] = true
# Update fog based on visible positions.
# player_pos reserved for future fog-decay tracking (#113).
func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void:
if not _initialized:
return
clear()
if _all_tile_positions.is_empty() or visible_positions.is_empty():
return
# Build set of fog-edge positions (8-directional neighbors of visible tiles)
var fog_edge: Dictionary = {}
var neighbors := [
Vector2i(-1, 0), Vector2i(1, 0), Vector2i(0, -1), Vector2i(0, 1),
Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1),
]
for pos in visible_positions:
for offset in neighbors:
var neighbor_pos: Vector2i = pos + offset
if not visible_positions.has(neighbor_pos) and _all_tile_positions.has(neighbor_pos):
fog_edge[neighbor_pos] = true
# Place fog tiles on all known positions that aren't visible
for pos in _all_tile_positions:
if visible_positions.has(pos):
continue # Visible — no fog
elif fog_edge.has(pos):
set_cell(pos, 0, Vector2i(1, 0)) # Fog edge — semi-transparent
else:
set_cell(pos, 0, Vector2i(0, 0)) # Full fog — opaque
+61
View File
@@ -0,0 +1,61 @@
extends Node2D
## Fog overlay controller — manages ColorRect + shader uniforms for D-059 fog.
## Reads textures from FogState autoload, positions rect to cover viewport.
## Architecture: docs/architecture/fog-shader-spec.md
var _fog_rect: ColorRect
var _shader_mat: ShaderMaterial
const TILE_SIZE := float(Constants.TILE_SIZE)
func _ready() -> void:
# Create the fog overlay ColorRect
_fog_rect = ColorRect.new()
_fog_rect.name = "FogRect"
add_child(_fog_rect)
# Load shader and create material
var shader := load("res://shaders/fog.gdshader") as Shader
_shader_mat = ShaderMaterial.new()
_shader_mat.shader = shader
_fog_rect.material = _shader_mat
# Create seamless noise texture for fog animation
var noise := FastNoiseLite.new()
noise.noise_type = FastNoiseLite.TYPE_PERLIN
noise.frequency = 0.03
var noise_tex := NoiseTexture2D.new()
noise_tex.noise = noise
noise_tex.width = 256
noise_tex.height = 256
noise_tex.seamless = true
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
print("FogShader: Initialized (D-059 5-layer)")
func update_fog() -> void:
# 1. Update FogState textures from GameState
FogState.update_from_state()
# 2. Position ColorRect to cover the current viewport
var vp_size := get_viewport().get_visible_rect().size
var cam := get_viewport().get_camera_2d()
var zoom := cam.zoom if cam else Vector2(2.0, 2.0)
var camera_pos := GameState.player_position * TILE_SIZE
var half_view := vp_size / (2.0 * zoom)
_fog_rect.position = camera_pos - half_view
_fog_rect.size = vp_size / zoom
# 3. Update shader uniforms
_shader_mat.set_shader_parameter("visibility_tex", FogState.visibility_texture)
_shader_mat.set_shader_parameter("exploration_tex", FogState.exploration_texture)
_shader_mat.set_shader_parameter("zone_tint_tex", FogState.zone_tint_texture)
_shader_mat.set_shader_parameter("rect_pos", _fog_rect.position)
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
+15 -8
View File
@@ -2,16 +2,25 @@ extends Node2D
# World renderer — manages all visual representation from GameState
# Attached to the World node in main.tscn
# Render order (scene tree): TileMapLayer -> FogOverlay -> Entities
#
# D-049 Z-level rendering pipeline (z-layer-gap-analysis.md):
# FogGroup (CanvasGroup) composites world content:
# FloorTiles (TileMapLayer) — z:0 ground plane
# FloorObjects (Node2D) — z:10 cosmetic detail (placeholder)
# YSortGroup (Node2D, y_sort) — z:100 furniture + entities (all z:0 relative)
# Furniture (Node2D, y_sort) — z:0 placed objects (placeholder)
# Entities (Node2D, y_sort) — z:0 entity sprites (D-033 colors)
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
@onready var tile_renderer = $TileMapLayer
@onready var tile_renderer = $FogGroup/FloorTiles
@onready var fog_renderer = $FogOverlay
@onready var entity_renderer = $Entities
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
var _last_tick: int = -1
func _ready() -> void:
print("WorldRenderer: Initialized")
print("WorldRenderer: Initialized (D-049 z-stack)")
# Called each frame to update visuals from game state.
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
@@ -25,12 +34,10 @@ func update_from_state() -> void:
if tile_renderer and tile_renderer.has_method("update_tiles"):
if GameState.visible_tiles.size() > 0:
tile_renderer.update_tiles(GameState.visible_tiles)
if fog_renderer and fog_renderer.has_method("register_tile_positions"):
fog_renderer.register_tile_positions(GameState.visible_tiles)
# Update fog overlay from visibility data
# Update fog overlay — shader-based, reads from FogState autoload
if fog_renderer and fog_renderer.has_method("update_fog"):
fog_renderer.update_fog(GameState.visible_positions, GameState.player_position)
fog_renderer.update_fog()
# Update entity sprites
if entity_renderer and entity_renderer.has_method("update_entities"):
+74
View File
@@ -0,0 +1,74 @@
shader_type canvas_item;
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
// Layer 5: Unexplored, no maps — solid near-black #12141a
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
uniform sampler2D noise_tex : filter_linear, repeat_enable;
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
uniform vec2 map_offset; // map_bounds.position (tiles)
uniform vec2 map_size; // map_bounds.size (tiles)
uniform float tile_size; // Pixels per sim tile
uniform float time; // Seconds since start
// D-059 fog layer colors
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
// D-059 thresholds (after bilinear filtering)
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
const float PERIPHERAL_LOW = 0.55; // Below this: transition to deep/unexplored
void fragment() {
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
vec2 world_px = rect_pos + UV * rect_sz;
vec2 tile = world_px / tile_size;
// Map tile coordinate to texture UV
vec2 tex_uv = (tile - map_offset) / map_size;
// Outside known map → unexplored
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
return;
}
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (vis > PERIPHERAL_LOW) {
// In or near vision cone
if (vis > CLEAR_THRESHOLD) {
// Layer 1: Clear — soft edge gradient
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
} else {
// Layer 2: Light fog (peripheral + forward edge)
// D-059: animated Perlin noise, 8-10s cycle
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
COLOR = vec4(DARK_OVERLAY, alpha);
}
} else if (explored > 0.3) {
// Layer 3: Deep fog (previously explored, no longer in LOS)
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
COLOR = vec4(tint_color, alpha);
} else {
// Layer 5: Unexplored, no maps — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+319
View File
@@ -0,0 +1,319 @@
## D-056: Cursor state machine tests (#429).
## Tests 4-state cursor transitions, timing, color accuracy, z-layer,
## and interaction suppression behavior per D-056 spec.
##
## These tests validate the cursor implementation once #429 lands.
## Some tests use mock data; others require the cursor scene/script.
##
## Spec refs: D-056, D-033, D-045, D-049
class_name TestCursorStates
extends GdUnitTestSuite
# -- Test helpers --------------------------------------------------------------
## Create cursor node from scene. Returns null if scene doesn't exist yet.
## Tests that call this will be skipped (not failed) if cursor isn't implemented.
func _make_cursor() -> Node:
var scene_path := "res://ui/cursor_state_machine.tscn"
if not ResourceLoader.exists(scene_path):
# Try alternative paths
for alt in ["res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
if ResourceLoader.exists(alt):
scene_path = alt
break
if not ResourceLoader.exists(scene_path):
return null
var scene = load(scene_path)
var cursor = scene.instantiate()
add_child(cursor)
return cursor
func _make_cursor_or_skip() -> Node:
var cursor = _make_cursor()
if cursor == null:
# Skip test: cursor not yet implemented
push_warning("TestCursorStates: cursor scene not found — test skipped (awaiting #429 implementation)")
return cursor
# -- State enumeration (D-056: exactly 4 states) -----------------------------
func test_cursor_has_four_states() -> void:
# Verify the cursor system defines exactly 4 states per D-056
var cursor = _make_cursor_or_skip()
if cursor == null:
return
assert_that(cursor.has_method("get_state")).is_true()
# Default state should be the starting state
var initial_state = cursor.get_state()
assert_that(initial_state).is_not_null()
cursor.queue_free()
func test_cursor_initial_state_is_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Default state — four thin inward-pointing ticks
var state = cursor.get_state()
assert_that(str(state)).is_equal("Default")
cursor.queue_free()
# -- State transitions (D-056: all transitions 150ms linear) ------------------
func test_transition_default_to_entity_hover() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# Simulate hovering over an NPC entity
if cursor.has_method("set_hover_target"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_transition_default_to_object_hover() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("ObjectHover")
cursor.queue_free()
func test_transition_default_to_weapon_aim() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_weapon_mode"):
cursor.set_weapon_mode(true)
assert_that(str(cursor.get_state())).is_equal("WeaponAim")
cursor.queue_free()
func test_transition_entity_hover_back_to_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target") and cursor.has_method("clear_hover_target"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
cursor.clear_hover_target()
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
func test_transition_object_hover_back_to_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target") and cursor.has_method("clear_hover_target"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown"})
cursor.clear_hover_target()
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
# -- Timing (D-056: all transitions 150ms linear) ----------------------------
func test_transition_duration_constant() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: "All transitions 150ms linear"
if cursor.has_method("get_transition_duration"):
var duration = cursor.get_transition_duration()
assert_float(duration).is_equal_approx(0.15, 0.001)
cursor.queue_free()
# -- Color accuracy (D-056 + D-033) -------------------------------------------
func test_default_cursor_color() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Default = white-blue #c8d0e0
if cursor.has_method("get_cursor_color"):
var color = cursor.get_cursor_color()
var expected = Color("#c8d0e0")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_object_hover_color_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Object hover = muted grey #8b8ba0
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown", "flagged": false})
var color = cursor.get_cursor_color()
var expected = Color("#8b8ba0")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_object_hover_color_flagged() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Object hover (flagged) = amber #e8c547
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown", "flagged": true})
var color = cursor.get_cursor_color()
var expected = Color("#e8c547")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_weapon_aim_color() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Weapon aim = warm white #f0e8d8, NO bloom
if cursor.has_method("set_weapon_mode") and cursor.has_method("get_cursor_color"):
cursor.set_weapon_mode(true)
var color = cursor.get_cursor_color()
var expected = Color("#f0e8d8")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
# -- Entity hover: D-033 relationship colors ----------------------------------
func test_entity_hover_unknown_uses_teal() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Unknown/Neutral = #4a9ebb
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
var color = cursor.get_cursor_color()
var expected = Color("#4a9ebb")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_friendly_uses_green() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Known/Friendly = #6bc9a6
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Friendly"})
var color = cursor.get_cursor_color()
var expected = Color("#6bc9a6")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_poi_uses_amber() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Person of Interest = #e8c547
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "PersonOfInterest"})
var color = cursor.get_cursor_color()
var expected = Color("#e8c547")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_hostile_uses_red() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Hostile/Dangerous = #d45d5d
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Hostile"})
var color = cursor.get_cursor_color()
var expected = Color("#d45d5d")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
# -- Z-layer (D-049: cursor on layer 7) --------------------------------------
func test_cursor_z_layer() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: cursor on UI layer (CanvasLayer 20)
if cursor.has_method("get_z_layer"):
assert_that(cursor.get_z_layer()).is_equal(Constants.CANVAS_UI)
cursor.queue_free()
# -- Weapon mode suppression (D-056) -----------------------------------------
func test_weapon_mode_suppresses_interactions() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: "Weapon-selected mode suppresses interaction prompts unless Shift held"
if cursor.has_method("set_weapon_mode") and cursor.has_method("should_show_interactions"):
cursor.set_weapon_mode(true)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_weapon_mode_shift_override() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Shift held in weapon mode restores interaction prompts
if cursor.has_method("set_weapon_mode") and cursor.has_method("should_show_interactions"):
cursor.set_weapon_mode(true)
if cursor.has_method("set_shift_held"):
cursor.set_shift_held(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Zone/narrative state invariance (D-045) ----------------------------------
func test_cursor_never_changes_by_zone() -> void:
# D-056: "Never changes by zone/narrative state (D-045)"
# This is a design constraint, not a unit test — but we verify the cursor
# doesn't accept zone/narrative state parameters.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
assert_that(cursor.has_method("set_zone")).is_false()
assert_that(cursor.has_method("set_narrative_state")).is_false()
cursor.queue_free()
# -- LOS-gated cursor changes (D-056) ----------------------------------------
func test_cursor_changes_require_los() -> void:
# D-056: "Cursor changes on LOS, not just proximity"
# Entity hover should only activate if the entity is in LOS
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target"):
# Entity NOT in LOS → cursor should stay Default
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": false})
# Should not transition to EntityHover if entity is not in LOS
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
# -- Interaction range (D-056) ------------------------------------------------
func test_click_interaction_range() -> void:
# D-056: "Click interaction range: ~2 sim tiles"
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("get_interaction_range"):
var range_val = cursor.get_interaction_range()
# ~2 sim tiles = 1m per D-066
assert_that(range_val).is_equal(2)
cursor.queue_free()
+297
View File
@@ -0,0 +1,297 @@
## D-059: Fog shader rebuild tests (#430).
## Validates FogState data management, fog shader integration,
## performance budget, and regression against old fog_renderer.gd API.
##
## Architecture reference: docs/architecture/fog-shader-spec.md
## Spec refs: D-059, D-049, D-033, D-060, D-066
class_name TestFogShader
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
func _fog_state_exists() -> bool:
# FogState should be an autoload once #430 lands
return Engine.has_singleton("FogState") or get_node_or_null("/root/FogState") != null
func _get_fog_state() -> Node:
var node = get_node_or_null("/root/FogState")
if node == null:
push_warning("TestFogShader: FogState autoload not found — test skipped (awaiting #430)")
return node
func _fog_shader_script_exists() -> bool:
return ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")
func _fog_gdshader_exists() -> bool:
return ResourceLoader.exists("res://shaders/fog.gdshader")
# -- FogState autoload existence -----------------------------------------------
func test_fog_state_autoload_registered() -> void:
# fog-shader-spec.md: "New autoload: client/scripts/autoloads/fog_state.gd"
if not _fog_state_exists():
push_warning("TestFogShader: FogState autoload not registered — test skipped")
return
var fog_state = _get_fog_state()
assert_that(fog_state).is_not_null()
# -- FogState texture management -----------------------------------------------
func test_fog_state_has_visibility_texture() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("visibility_texture") != null).is_true()
func test_fog_state_has_exploration_texture() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("exploration_texture") != null).is_true()
func test_fog_state_has_zone_tint_texture() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("zone_tint_texture") != null).is_true()
func test_fog_state_has_map_bounds() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("map_bounds") != null).is_true()
# -- FogState update from GameState -------------------------------------------
func test_fog_state_update_from_visible_positions() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
# Set up GameState with visible positions
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward", Vector2i(6, 5): "Peripheral"}
if fog_state.has_method("update_from_state"):
fog_state.update_from_state()
# Visibility texture should be updated (non-null)
assert_that(fog_state.visibility_texture).is_not_null()
# Reset
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_fog_state_exploration_persists() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Tick 1: see tile (5,5)
GameState.visible_positions = {Vector2i(5, 5): true}
fog_state.update_from_state()
# Tick 2: no longer see tile (5,5) but it should remain explored
GameState.visible_positions.clear()
fog_state.update_from_state()
assert_that(fog_state.exploration_texture).is_not_null()
# The exploration state for (5,5) should be non-zero (explored, deep fog)
# Exact value depends on implementation — test that it's not unexplored
GameState.visible_positions.clear()
func test_fog_state_forward_vs_peripheral() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Forward tiles should have higher visibility value than Peripheral
GameState.visible_positions = {
Vector2i(5, 5): true,
Vector2i(6, 5): true,
}
GameState.visibility_sectors = {
Vector2i(5, 5): "Forward",
Vector2i(6, 5): "Peripheral",
}
fog_state.update_from_state()
# Per spec: Forward = 255, Peripheral = 180
assert_that(fog_state.visibility_texture).is_not_null()
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Fog shader script existence -----------------------------------------------
func test_fog_shader_script_exists() -> void:
if not _fog_shader_script_exists():
push_warning("TestFogShader: fog_shader.gd not found — test skipped")
return
assert_that(_fog_shader_script_exists()).is_true()
func test_fog_gdshader_file_exists() -> void:
if not _fog_gdshader_exists():
push_warning("TestFogShader: fog.gdshader not found — test skipped")
return
assert_that(_fog_gdshader_exists()).is_true()
# -- Old fog_renderer.gd should be deleted ------------------------------------
func test_old_fog_renderer_deleted() -> void:
# fog-shader-spec.md: "Delete fog_renderer.gd"
# This test will PASS once the old file is removed, FAIL if it still exists
# alongside the new fog shader.
if not _fog_shader_script_exists():
# New fog shader hasn't landed yet — skip this check
return
var old_exists = ResourceLoader.exists("res://scripts/rendering/fog_renderer.gd")
assert_that(old_exists).is_false()
# -- Performance: texture update budget (<1ms/frame total) --------------------
func test_visibility_texture_update_performance() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Simulate a realistic tile count (~400 visible tiles)
var positions := {}
var sectors := {}
for x in range(20):
for y in range(20):
var pos := Vector2i(x, y)
positions[pos] = true
sectors[pos] = "Forward" if y < 10 else "Peripheral"
GameState.visible_positions = positions
GameState.visibility_sectors = sectors
# Measure update time
var start := Time.get_ticks_usec()
fog_state.update_from_state()
var elapsed_us := Time.get_ticks_usec() - start
var elapsed_ms := elapsed_us / 1000.0
# D-059: Visibility texture upload budget: 0.1ms
# Allow 2x margin for test environment overhead
assert_that(elapsed_ms).is_less(0.5)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_full_fog_update_under_1ms() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# D-059: <1ms/frame total for fog system (CPU side)
var positions := {}
var sectors := {}
for x in range(20):
for y in range(20):
positions[Vector2i(x, y)] = true
sectors[Vector2i(x, y)] = "Forward"
GameState.visible_positions = positions
GameState.visibility_sectors = sectors
var start := Time.get_ticks_usec()
fog_state.update_from_state()
var elapsed_us := Time.get_ticks_usec() - start
var elapsed_ms := elapsed_us / 1000.0
# Allow 2x margin: spec says <1ms, we allow <2ms for test overhead
assert_that(elapsed_ms).is_less(2.0)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Shader uniform constants (D-059) -----------------------------------------
func test_fog_layer_color_constants() -> void:
# D-059 fog layer colors — verify constants are defined correctly
# Layer 5: Unexplored no maps = #12141a
var unexplored := Color("#12141a")
assert_float(unexplored.r).is_equal_approx(0.071, 0.01)
assert_float(unexplored.g).is_equal_approx(0.078, 0.01)
assert_float(unexplored.b).is_equal_approx(0.102, 0.01)
# Layer 4: Wireframe = #333340
var wireframe := Color("#333340")
assert_float(wireframe.r).is_equal_approx(0.2, 0.01)
assert_float(wireframe.g).is_equal_approx(0.2, 0.01)
assert_float(wireframe.b).is_equal_approx(0.251, 0.01)
# -- Regression: GameState visible_positions still works ----------------------
func test_game_state_visible_positions_unchanged() -> void:
# Ensure the fog shader doesn't break the visible_positions Dictionary
# that fog_renderer.gd used to consume
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "visibility": "Peripheral"},
],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
assert_that(GameState.visibility_sectors[Vector2i(5, 5)]).is_equal("Forward")
assert_that(GameState.visibility_sectors[Vector2i(6, 5)]).is_equal("Peripheral")
func test_game_state_visible_positions_cleared_on_new_snapshot() -> void:
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
})
assert_that(GameState.visible_positions.size()).is_equal(1)
GameState.apply_snapshot({
"tick": 2,
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_false()
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
# -- Z-layer compliance (D-049) -----------------------------------------------
func test_fog_overlay_z_layer() -> void:
# fog-shader-spec.md: FogOverlay at z:10 (above world content)
# Per D-049, fog entities render on Layer 5
# The FogOverlay itself renders ABOVE the FogGroup (CanvasGroup)
if not _fog_shader_script_exists():
push_warning("TestFogShader: fog_shader.gd not found — z-layer test skipped")
return
# This test needs the scene tree to be set up
# Verify via scene file inspection rather than runtime
pass
# -- Noise animation cycles (D-059) -------------------------------------------
func test_light_fog_noise_cycle() -> void:
# D-059: Light fog animated Perlin noise, 8-10s cycle
# This is a shader constant — verify documentation, not runtime
# The shader should use TIME with a period of 8-10s
pass # Manual verification required — shader inspection
func test_deep_fog_noise_cycle() -> void:
# D-059: Deep fog more pronounced noise, 15-20s cycle
# Shader constant — manual verification
pass # Manual verification required — shader inspection
+1
View File
@@ -0,0 +1 @@
uid://st3laseti33k
+351
View File
@@ -0,0 +1,351 @@
## D-057: Entity interaction vertical list tests (#432).
## Tests multi-verb vertical list rendering, insert styling, z-layer 6,
## verb click dispatch, and D-055 sprint suppression integration.
##
## Also covers stance toggle input encoding (D-053, #439).
## Spec refs: D-057, D-056, D-055, D-053, D-049
class_name TestInteractionList
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
func _interaction_list_exists() -> bool:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
return true
return false
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
add_child(node)
return node
return null
func _make_list_or_skip() -> Node:
var list = _make_interaction_list()
if list == null:
push_warning("TestInteractionList: interaction list scene not found — test skipped (awaiting #432)")
return list
# -- Multi-verb rendering (D-057: 2-4 options max) ----------------------------
func test_list_renders_two_verbs() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
],
}]
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(2)
list.queue_free()
GameState.nearby_interactions = []
func test_list_renders_four_verbs_max() -> void:
var list = _make_list_or_skip()
if list == null:
return
# D-057: "2-4 options max"
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
{"kind": "Confront", "label": "Confront", "priority": 3, "available": true},
{"kind": "Observe", "label": "Look at", "priority": 4, "available": true},
],
}]
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_less_equal(4)
list.queue_free()
GameState.nearby_interactions = []
func test_list_empty_when_no_interactions() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = []
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
func test_list_hidden_when_no_interactions() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = []
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_false()
list.queue_free()
# -- Verb click dispatch (D-057) -----------------------------------------------
func test_get_selected_verb_returns_kind() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
if list.has_method("get_selected_verb"):
# Default selection should be the first (highest priority) verb
assert_that(list.get_selected_verb()).is_equal("Talk")
list.queue_free()
GameState.nearby_interactions = []
func test_get_interaction_target_returns_entity_id() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 42, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
if list.has_method("get_interaction_target"):
assert_that(list.get_interaction_target()).is_equal(42)
list.queue_free()
GameState.nearby_interactions = []
# -- Verb priority ordering (D-057: sorted by priority) -----------------------
func test_verbs_sorted_by_priority() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "ExamineNpc", "label": "Observe", "priority": 3, "available": true},
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "Confront", "label": "Confront", "priority": 2, "available": true},
],
}]
if list.has_method("update_from_state") and list.has_method("get_verb_labels"):
list.update_from_state()
var labels = list.get_verb_labels()
# Should be sorted: Talk (1), Confront (2), Observe (3)
if labels.size() >= 3:
assert_that(labels[0]).is_equal("Talk")
assert_that(labels[1]).is_equal("Confront")
assert_that(labels[2]).is_equal("Observe")
list.queue_free()
GameState.nearby_interactions = []
# -- D-055: Sprint suppression ------------------------------------------------
func test_sprint_suppresses_interaction_list() -> void:
# D-055: Sprint stance clears interaction buffer — no verbs should show
var list = _make_list_or_skip()
if list == null:
return
# Set up interactions
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
# Set stance to Sprint
GameState.player_stance = "Sprint"
if list.has_method("update_from_state"):
list.update_from_state()
# The list should be hidden when sprinting
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
func test_walk_shows_interaction_list() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
GameState.player_stance = "Walk"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_true()
list.queue_free()
GameState.nearby_interactions = []
func test_careful_shows_interaction_list() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
GameState.player_stance = "Careful"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_true()
list.queue_free()
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
# -- Z-layer compliance (D-049: interaction list on layer 6) ------------------
func test_interaction_list_z_layer() -> void:
var list = _make_list_or_skip()
if list == null:
return
# D-057: Labels render on insert overlay (CanvasLayer 10)
if list.has_method("get_z_layer"):
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -- Diegetic test (D-057: insert off → labels disappear) --------------------
func test_insert_off_hides_interaction_list() -> void:
var list = _make_list_or_skip()
if list == null:
return
# D-057: "If insert is off, labels disappear"
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
if list.has_method("set_insert_active"):
list.set_insert_active(false)
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_false()
list.queue_free()
GameState.nearby_interactions = []
# -- Stance toggle wire mapping (D-053) ----------------------------------------
func test_stance_up_wire_mapping() -> void:
# Verify InputMapper.Action.TOGGLE_STANCE_UP maps to "ToggleStanceUp" wire name
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
assert_that(wire).is_equal("ToggleStanceUp")
func test_stance_down_wire_mapping() -> void:
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
assert_that(wire).is_equal("ToggleStanceDown")
func test_all_movement_actions_have_wire_mapping() -> void:
# Verify no action produces an empty wire name (except OPEN_MENU which is client-only)
var actions_with_mapping := [
InputMapper.Action.MOVE_NORTH,
InputMapper.Action.MOVE_NORTHEAST,
InputMapper.Action.MOVE_EAST,
InputMapper.Action.MOVE_SOUTHEAST,
InputMapper.Action.MOVE_SOUTH,
InputMapper.Action.MOVE_SOUTHWEST,
InputMapper.Action.MOVE_WEST,
InputMapper.Action.MOVE_NORTHWEST,
InputMapper.Action.INTERACT,
InputMapper.Action.USE_PERCEPTION_MODE,
InputMapper.Action.PAUSE,
InputMapper.Action.TOGGLE_STANCE_UP,
InputMapper.Action.TOGGLE_STANCE_DOWN,
]
for action in actions_with_mapping:
var wire = SimBridge._action_enum_to_wire(action)
assert_that(wire.length()).is_greater(0)
# -- Contradicted entity indicator (D-057 + #422) ----------------------------
func test_contradicted_entity_verbs_decode() -> void:
# #422: NearbyInteraction.contradicted=true should be decodeable
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
"contradicted": true,
}],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.nearby_interactions.size()).is_equal(1)
# Note: contradicted field may not be decoded yet in protocol.gd
# This test documents the expected behavior for #422 integration
# -- Object type verb sets (D-057 Phase 1) ------------------------------------
func test_object_type_verbs_decode() -> void:
# #421: ObjectType appears in NearbyInteraction.object_type
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{
"entity_id": 3,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Open", "label": "Open", "priority": 1, "available": true},
{"kind": "Search", "label": "Search", "priority": 2, "available": true},
],
"object_type": "Container",
}],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.nearby_interactions.size()).is_equal(1)
assert_that(snapshot.nearby_interactions[0].verbs.size()).is_equal(2)
assert_that(snapshot.nearby_interactions[0].verbs[0].kind).is_equal("Open")
assert_that(snapshot.nearby_interactions[0].verbs[1].kind).is_equal("Search")
+8 -8
View File
@@ -11,7 +11,7 @@ extends GdUnitTestSuite
func test_protocol_decode_v4_with_nearby_interactions() -> void:
var raw := {
"tick": 10,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
@@ -46,7 +46,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
func test_protocol_decode_v4_no_nearby_interactions() -> void:
var raw := {
"tick": 5,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -57,7 +57,7 @@ func test_protocol_decode_v4_no_nearby_interactions() -> void:
func test_protocol_decode_empty_nearby_interactions() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [],
}
@@ -68,7 +68,7 @@ func test_protocol_decode_empty_nearby_interactions() -> void:
func test_protocol_decode_interaction_missing_verbs() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{"entity_id": 2}],
}
@@ -79,7 +79,7 @@ func test_protocol_decode_interaction_missing_verbs() -> void:
func test_protocol_decode_interaction_empty_verbs() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}],
}
@@ -90,7 +90,7 @@ func test_protocol_decode_interaction_empty_verbs() -> void:
func test_protocol_decode_v4_entity_relationship() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
@@ -153,10 +153,10 @@ func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.nearby_interactions.size()).is_equal(1)
func test_sim_bridge_test_snapshot_v4_version() -> void:
func test_sim_bridge_test_snapshot_protocol_version() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(4)
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
# -- InteractionPrompt UI --
+2 -2
View File
@@ -280,7 +280,7 @@ func test_decode_snapshot_v2_full() -> void:
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(500)
assert_that(snapshot.version).is_equal(4)
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
# game_time
assert_that(snapshot.game_time).is_not_null()
@@ -307,7 +307,7 @@ func test_existing_fixtures_have_v2_fields() -> void:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(4)
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.player_facing).is_equal("North")
assert_that(snapshot.game_time).is_not_null()
+402
View File
@@ -0,0 +1,402 @@
## D-030 Layer 1: Protocol v6 tests for ObserverSnapshot bridge upgrade (#449).
## Validates player_stance (D-053) and player_inventory (D-065) decode,
## GameState storage, SimBridge test mode, and input encoding for stance toggles.
## Spec refs: D-053, D-065, D-020, #449
class_name TestProtocolV6
extends GdUnitTestSuite
const FIXTURE_DIR = "res://tests/fixtures/msgpack/"
# TODO: Add .msgpack fixture files for v6 stance/inventory variations.
# Current fixtures (snapshot_one_npc, snapshot_empty, etc.) only cover default
# Walk stance with empty inventory. Missing fixtures:
# - snapshot with player_stance = Sprint, Careful, Crouch
# - snapshot with populated player_inventory (1-item, 9-item full grid)
# - combined stance + inventory variations
# Stance/inventory decode is tested via in-memory Messagepack.encode() above,
# but regression-safe .msgpack fixtures are needed for bridge contract coverage.
func _load_fixture(name: String) -> PackedByteArray:
var path = FIXTURE_DIR + name + ".msgpack"
var file = FileAccess.open(path, FileAccess.READ)
assert_that(file).is_not_null()
return file.get_buffer(file.get_length())
# -- Protocol version upgrade -------------------------------------------------
func test_protocol_version_is_6() -> void:
assert_that(Protocol.PROTOCOL_VERSION).is_equal(6)
func test_fixtures_at_protocol_version_6() -> void:
# All regenerated fixtures should be at v6
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(6)
func test_rejects_version_5() -> void:
var raw := {"tick": 1, "version": 5, "entities": []}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_null()
# -- player_stance decode (D-053) ---------------------------------------------
func test_decode_player_stance_walk() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_stance": "Walk",
"player_inventory": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.player_stance).is_equal("Walk")
func test_decode_player_stance_sprint() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_stance": "Sprint",
"player_inventory": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_stance).is_equal("Sprint")
func test_decode_player_stance_careful() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_stance": "Careful",
"player_inventory": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_stance).is_equal("Careful")
func test_decode_player_stance_crouch() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_stance": "Crouch",
"player_inventory": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_stance).is_equal("Crouch")
func test_decode_player_stance_missing_defaults_to_walk() -> void:
# v6 snapshot without player_stance → should default to "Walk"
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.player_stance).is_equal("Walk")
# -- player_inventory decode (D-065) ------------------------------------------
func test_decode_empty_inventory() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_stance": "Walk",
"player_inventory": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_inventory.size()).is_equal(0)
func test_decode_smuggler_inventory_3_items() -> void:
# D-065: smuggler carries 3 specific items
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_stance": "Walk",
"player_inventory": [
{"item_id": 100, "name": "Manifest Copy", "slot": 0},
{"item_id": 101, "name": "Access Token", "slot": 1},
{"item_id": 102, "name": "Comm Log", "slot": 2},
],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_inventory.size()).is_equal(3)
assert_that(snapshot.player_inventory[0].item_id).is_equal(100)
assert_that(snapshot.player_inventory[0].name).is_equal("Manifest Copy")
assert_that(snapshot.player_inventory[0].slot).is_equal(0)
assert_that(snapshot.player_inventory[1].name).is_equal("Access Token")
assert_that(snapshot.player_inventory[1].slot).is_equal(1)
assert_that(snapshot.player_inventory[2].name).is_equal("Comm Log")
assert_that(snapshot.player_inventory[2].slot).is_equal(2)
func test_decode_full_9_slot_inventory() -> void:
# D-065: 3x3 grid = 9 slots universal
var items: Array = []
for i in range(9):
items.append({"item_id": 100 + i, "name": "Item %d" % i, "slot": i})
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_inventory": items,
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_inventory.size()).is_equal(9)
assert_that(snapshot.player_inventory[0].slot).is_equal(0)
assert_that(snapshot.player_inventory[8].slot).is_equal(8)
assert_that(snapshot.player_inventory[8].item_id).is_equal(108)
func test_decode_inventory_missing_defaults_to_empty() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_inventory.size()).is_equal(0)
func test_decode_inventory_skips_malformed_items() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_inventory": [
{"item_id": 100, "name": "Valid Item", "slot": 0},
{"broken": true}, # Missing item_id and name
{"item_id": 101}, # Missing name
{"name": "No ID"}, # Missing item_id
{"item_id": 102, "name": "Also Valid", "slot": 3},
],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
# Only items with both item_id and name should decode
assert_that(snapshot.player_inventory.size()).is_equal(2)
assert_that(snapshot.player_inventory[0].name).is_equal("Valid Item")
assert_that(snapshot.player_inventory[1].name).is_equal("Also Valid")
func test_decode_inventory_item_slot_defaults_to_zero() -> void:
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"player_inventory": [
{"item_id": 100, "name": "No Slot"},
],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.player_inventory[0].slot).is_equal(0)
# -- GameState: v6 field storage -----------------------------------------------
func test_game_state_stores_player_stance() -> void:
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Sprint"})
assert_that(GameState.player_stance).is_equal("Sprint")
GameState.player_stance = "Walk" # Reset
func test_game_state_stores_player_inventory() -> void:
var inv := [
{"item_id": 100, "name": "Manifest Copy", "slot": 0},
{"item_id": 101, "name": "Access Token", "slot": 1},
]
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inv})
assert_that(GameState.player_inventory.size()).is_equal(2)
assert_that(GameState.player_inventory[0].name).is_equal("Manifest Copy")
GameState.player_inventory = [] # Reset
func test_game_state_clears_inventory_when_absent() -> void:
var inv := [{"item_id": 100, "name": "Item", "slot": 0}]
GameState.apply_snapshot({"tick": 1, "entities": [], "player_inventory": inv})
assert_that(GameState.player_inventory.size()).is_equal(1)
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.player_inventory.size()).is_equal(0)
func test_game_state_stance_persists_when_absent() -> void:
# Stance should NOT reset when field is missing — keep last known value
GameState.apply_snapshot({"tick": 1, "entities": [], "player_stance": "Careful"})
assert_that(GameState.player_stance).is_equal("Careful")
GameState.apply_snapshot({"tick": 2, "entities": []})
# Stance persists (no explicit reset to Walk when absent)
assert_that(GameState.player_stance).is_equal("Careful")
GameState.player_stance = "Walk" # Reset
func test_game_state_defaults() -> void:
# Default values before any snapshot
var gs_stance = GameState.player_stance
var gs_inv = GameState.player_inventory
assert_that(gs_stance).is_equal("Walk")
assert_that(gs_inv.size()).is_equal(0)
# -- SimBridge test mode: v6 fields -------------------------------------------
func test_sim_bridge_test_snapshot_has_player_stance() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("player_stance")).is_true()
assert_that(snap.player_stance).is_equal("Walk")
func test_sim_bridge_test_snapshot_has_player_inventory() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("player_inventory")).is_true()
assert_that(snap.player_inventory is Array).is_true()
func test_sim_bridge_test_snapshot_version_6() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(6)
# -- Fixture: v6 snapshots include new fields ----------------------------------
func test_fixture_snapshots_have_v6_defaults() -> void:
# All regenerated fixtures should have player_stance=Walk and empty inventory
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player"]:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.player_stance).is_equal("Walk")
assert_that(snapshot.player_inventory.size()).is_equal(0)
# -- Input encoding: stance toggle actions (D-053) ----------------------------
func test_encode_toggle_stance_up() -> void:
var inputs: Array = [{"tick": 10, "action_name": "ToggleStanceUp"}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"]).is_equal("ToggleStanceUp")
func test_encode_toggle_stance_down() -> void:
var inputs: Array = [{"tick": 10, "action_name": "ToggleStanceDown"}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"]).is_equal("ToggleStanceDown")
func test_encode_decode_stance_toggle_roundtrip() -> void:
for action_name in ["ToggleStanceUp", "ToggleStanceDown"]:
var bytes = Protocol.encode_player_input(50, action_name)
assert_that(bytes.size()).is_greater(0)
var decoded = Protocol.decode_player_input(bytes)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(50)
assert_that(decoded.action.variant).is_equal(action_name)
assert_that(decoded.action.data).is_null()
# -- Integration: full v6 snapshot round-trip ----------------------------------
func test_full_v6_snapshot_decode() -> void:
# Simulate a realistic v6 snapshot with all fields populated
var raw := {
"tick": 100,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {"day": 1, "time_of_day": 720, "day_phase": "Evening", "tick_rate": "Full"},
"player_facing": "Southeast",
"player_stance": "Careful",
"player_inventory": [
{"item_id": 100, "name": "Manifest Copy", "slot": 0},
{"item_id": 101, "name": "Access Token", "slot": 1},
{"item_id": 102, "name": "Comm Log", "slot": 2},
],
"entities": [
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
],
"visible_tiles": [
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "tile_kind": "Floor"},
],
"nearby_interactions": [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}],
"current_monologue": {
"id": "test_001",
"text": "Sova Transit. The usual crowd.",
"duration_seconds": 4.0,
},
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(100)
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snapshot.player_facing).is_equal("Southeast")
assert_that(snapshot.player_stance).is_equal("Careful")
assert_that(snapshot.player_inventory.size()).is_equal(3)
assert_that(snapshot.entities.size()).is_equal(1)
assert_that(snapshot.nearby_interactions.size()).is_equal(1)
assert_that(snapshot.current_monologue).is_not_null()
assert_that(snapshot.current_monologue.text).is_equal("Sova Transit. The usual crowd.")
func test_full_v6_snapshot_to_game_state() -> void:
var snapshot := {
"tick": 50,
"player_stance": "Crouch",
"player_inventory": [
{"item_id": 200, "name": "Access Token", "slot": 4},
],
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
],
}
GameState.apply_snapshot(snapshot)
assert_that(GameState.current_tick).is_equal(50)
assert_that(GameState.player_stance).is_equal("Crouch")
assert_that(GameState.player_inventory.size()).is_equal(1)
assert_that(GameState.player_inventory[0].name).is_equal("Access Token")
assert_that(GameState.player_inventory[0].slot).is_equal(4)
assert_that(GameState.player_position).is_equal(Vector2(5, 5))
# Reset
GameState.player_stance = "Walk"
GameState.player_inventory = []
+1 -37
View File
@@ -4,7 +4,6 @@ class_name TestRendering
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogRendererScript = load("res://scripts/rendering/fog_renderer.gd")
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
# -- Test data matching Protocol decoded format --
@@ -175,7 +174,7 @@ func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("version")).is_true()
assert_that(snap.version).is_equal(4)
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snap.has("game_time")).is_true()
assert_that(snap.has("player_facing")).is_true()
assert_that(snap.has("visible_tiles")).is_true()
@@ -330,41 +329,6 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
renderer.queue_free()
# -- FogRenderer: position registration --
func _make_fog_renderer() -> TileMapLayer:
var fog = TileMapLayer.new()
fog.set_script(FogRendererScript)
return fog
func test_fog_renderer_registers_positions() -> void:
var fog := _make_fog_renderer()
fog.register_tile_positions(_test_tiles)
assert_that(fog._all_tile_positions.size()).is_equal(5)
assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_true()
assert_that(fog._all_tile_positions.has(Vector2i(3, 0))).is_true()
assert_that(fog._all_tile_positions.has(Vector2i(0, 1))).is_true()
fog.free()
func test_fog_renderer_clears_on_re_register() -> void:
var fog := _make_fog_renderer()
fog.register_tile_positions(_test_tiles)
assert_that(fog._all_tile_positions.size()).is_equal(5)
fog.register_tile_positions([{"x": 10, "y": 10, "z": 0, "type": "floor"}])
assert_that(fog._all_tile_positions.size()).is_equal(1)
assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_false()
fog.free()
func test_fog_renderer_handles_empty_data() -> void:
var fog := _make_fog_renderer()
fog._initialized = true
fog.update_fog({}, Vector2.ZERO)
fog.register_tile_positions([])
assert_that(fog._all_tile_positions.size()).is_equal(0)
fog.free()
# -- TileRenderer: tile type constants --
func test_tile_type_map_covers_required_types() -> void:
+1
View File
@@ -0,0 +1 @@
uid://c6vjj0dwiy80i
+8
View File
@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/rendering/cursor_renderer.gd" id="1_cursor"]
; D-056: Cursor state machine — standalone scene for testing.
; In the main scene, this is instantiated as a child of UILayer (z-layer 7).
[node name="CursorStateMachine" type="Node2D"]
script = ExtResource("1_cursor")
+171
View File
@@ -0,0 +1,171 @@
extends Control
## D-057: Entity interaction vertical list — insert-styled, z-layer 6.
## Compact list of 2-4 verb options, anchored to entity position.
## Sprint suppression (D-055): hidden while sprinting.
## Diegetic test: labels disappear when insert is off.
##
## Replaces the single-line interaction_prompt for multi-verb scenarios.
## Public API matches QA test contract (test_interaction_list.gd).
signal verb_selected(kind: String, entity_id: int)
const MAX_VERBS := 4
# Intentionally faster than cursor 150ms — text must be readable quickly
const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
var _showing: bool = false
var _insert_active: bool = true
var _current_target_id: int = -1
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
var _verb_labels: Array[Label] = []
@onready var _vbox: VBoxContainer = $VBox
func _ready() -> void:
modulate.a = 0.0
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
# D-055: Sprint stance suppresses interaction list
if GameState.player_stance == "Sprint":
_hide()
return
# Diegetic toggle — insert off means no overlay data
if not _insert_active:
_hide()
return
var interactions: Array = GameState.nearby_interactions
if interactions.is_empty():
_hide()
return
var interaction: Dictionary = interactions[0]
var entity_id: int = interaction.get("entity_id", -1)
var verbs: Array = interaction.get("verbs", [])
if verbs.is_empty():
_hide()
return
# Sort by priority ascending, cap at MAX_VERBS
var sorted: Array = verbs.duplicate()
sorted.sort_custom(func(a, b): return a.get("priority", 99) < b.get("priority", 99))
if sorted.size() > MAX_VERBS:
sorted = sorted.slice(0, MAX_VERBS)
_current_target_id = entity_id
_verb_items = sorted
_selected_index = 0
_rebuild_labels()
_show()
func _rebuild_labels() -> void:
# Guard: tween callback from previous _hide() may fire after labels already freed
for lbl in _verb_labels:
if is_instance_valid(lbl):
lbl.queue_free()
_verb_labels.clear()
for i in range(_verb_items.size()):
var verb: Dictionary = _verb_items[i]
var lbl := Label.new()
lbl.text = verb.get("label", "")
lbl.add_theme_font_size_override("font_size", 14)
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_vbox.add_child(lbl)
_verb_labels.append(lbl)
func _show() -> void:
if _showing:
return
_showing = true
visible = true
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide() -> void:
if not _showing:
return
_showing = false
_current_target_id = -1
_verb_items.clear()
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
# Guard: tween callback from previous _hide() may fire after labels already freed
_active_tween.tween_callback(func():
visible = false
for lbl in _verb_labels:
if is_instance_valid(lbl):
lbl.queue_free()
_verb_labels.clear()
)
# -- Lazy sync for getters (tests may set GameState without calling update) --
func _ensure_synced() -> void:
if _current_target_id == -1 and not GameState.nearby_interactions.is_empty():
update_from_state()
# -- Public API (QA test contract) ------------------------------------------
func get_visible_verb_count() -> int:
_ensure_synced()
return _verb_items.size()
func is_showing() -> bool:
return _showing
func get_selected_verb() -> String:
_ensure_synced()
if _verb_items.is_empty():
return ""
return _verb_items[_selected_index].get("kind", "")
func get_interaction_target() -> int:
_ensure_synced()
return _current_target_id
func get_verb_labels() -> Array:
var labels: Array = []
for verb in _verb_items:
labels.append(verb.get("label", ""))
return labels
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _showing:
_hide()
func get_z_layer() -> int:
return Constants.CANVAS_INSERT
+16
View File
@@ -0,0 +1,16 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/interaction_list.gd" id="1_list"]
[node name="InteractionList" type="Control"]
layout_mode = 3
anchors_preset = 0
mouse_filter = 2
script = ExtResource("1_list")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 0
offset_right = 160.0
offset_bottom = 100.0
mouse_filter = 2
theme_override_constants/separation = 2
+139
View File
@@ -0,0 +1,139 @@
extends Control
## D-065: Inventory grid — 3x3 slots, bottom-right, 40x40px icons.
## No empty slots displayed — icons appear only when items are carried.
## 1-9 hotkeys for direct slot access. Reads from GameState.player_inventory.
## Lives on UILayer (z-layer 7).
signal item_selected(slot: int, item_id: int)
const SLOT_SIZE := 40
const SLOT_GAP := 4
const GRID_COLS := 3
const GRID_ROWS := 3
const MAX_SLOTS := 9
const SLOT_BG := Color(0.1, 0.1, 0.14, 0.6)
const SLOT_BORDER := Color(0.3, 0.3, 0.38, 0.8)
const SLOT_ACTIVE := Color("#c8d0e0")
const SLOT_TEXT := Color("#c8d0e0")
const SLOT_TEXT_DIM := Color("#8b8ba0")
const HOTKEY_SIZE := 10
var _slots: Array[Dictionary] = [] # [{item_id, name, slot}] from GameState
var _slot_nodes: Array[Control] = []
var _selected_slot: int = -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_grid()
visible = false
func _build_grid() -> void:
for node in _slot_nodes:
if is_instance_valid(node):
node.queue_free()
_slot_nodes.clear()
var grid_w: float = GRID_COLS * SLOT_SIZE + (GRID_COLS - 1) * SLOT_GAP
var grid_h: float = GRID_ROWS * SLOT_SIZE + (GRID_ROWS - 1) * SLOT_GAP
# Position grid at bottom-right with margin
custom_minimum_size = Vector2(grid_w, grid_h)
size = Vector2(grid_w, grid_h)
func update_from_state() -> void:
_slots = GameState.player_inventory.duplicate()
if _slots.is_empty():
visible = false
return
visible = true
queue_redraw()
func _draw() -> void:
if _slots.is_empty():
return
for item in _slots:
var slot_idx: int = item.get("slot", 0)
if slot_idx < 0 or slot_idx >= MAX_SLOTS:
continue
var col: int = slot_idx % GRID_COLS
var row: int = slot_idx / GRID_COLS
var pos := Vector2(
col * (SLOT_SIZE + SLOT_GAP),
row * (SLOT_SIZE + SLOT_GAP)
)
var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE))
# Slot background
draw_rect(rect, SLOT_BG)
# Border
draw_rect(rect, SLOT_BORDER, false, 1.0)
# Selected highlight
if slot_idx == _selected_slot:
draw_rect(rect, SLOT_ACTIVE, false, 2.0)
# Item name (truncated, centered)
var item_name: String = item.get("name", "?")
if item_name.length() > 5:
item_name = item_name.substr(0, 4) + "."
var font := ThemeDB.fallback_font
var font_size := 11
var text_size := font.get_string_size(item_name, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
var text_pos := pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
draw_string(font, text_pos, item_name, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, SLOT_TEXT)
# Hotkey number (top-left corner)
var hotkey := str(slot_idx + 1)
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey, HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
var key: int = event.physical_keycode
# 1-9 hotkeys (Key_1 = 49, Key_9 = 57)
if key >= KEY_1 and key <= KEY_9:
var slot_idx: int = key - KEY_1
_select_slot(slot_idx)
get_viewport().set_input_as_handled()
func _select_slot(slot_idx: int) -> void:
# Find item in this slot
for item in _slots:
if item.get("slot", -1) == slot_idx:
_selected_slot = slot_idx
item_selected.emit(slot_idx, item.get("item_id", -1))
queue_redraw()
return
# No item in slot — deselect
_selected_slot = -1
queue_redraw()
# -- Public API ---------------------------------------------------------------
func get_slot_count() -> int:
return _slots.size()
func get_selected_slot() -> int:
return _selected_slot
func get_item_at_slot(slot_idx: int) -> Dictionary:
for item in _slots:
if item.get("slot", -1) == slot_idx:
return item
return {}
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/inventory_grid.gd" id="1_inv"]
; D-065: 3x3 inventory grid, bottom-right, 40x40px icons
[node name="InventoryGrid" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -148.0
offset_top = -148.0
offset_right = -16.0
offset_bottom = -16.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
script = ExtResource("1_inv")
+51
View File
@@ -0,0 +1,51 @@
extends Control
## D-053: Stance indicator — shows current movement stance on HUD.
## Sprint/Walk/Careful/Crouch. Color-coded for quick read.
## Lives on UILayer (z-layer 7).
const STANCE_COLORS := {
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
"Careful": Color("#6bc9a6"), # Green — quiet, observant
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
}
const STANCE_DEFAULT_COLOR := Color("#c8d0e0")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
const FONT_SIZE := 13
const PADDING := Vector2(10, 6)
var _current_stance: String = "Walk"
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
var stance: String = GameState.player_stance
if stance == _current_stance:
return
_current_stance = stance
queue_redraw()
func _draw() -> void:
var font := ThemeDB.fallback_font
var text := _current_stance
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
var box_size := text_size + PADDING * 2
# Background
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
# Stance text
var color: Color = STANCE_COLORS.get(_current_stance, STANCE_DEFAULT_COLOR)
draw_string(font, PADDING + Vector2(0, text_size.y), text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
# -- Public API ---------------------------------------------------------------
func get_current_stance() -> String:
return _current_stance
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/stance_indicator.gd" id="1_stance"]
; D-053: Stance indicator — top-right HUD element
[node name="StanceIndicator" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -100.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = 42.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_stance")
+215
View File
@@ -0,0 +1,215 @@
extends Control
## D-058: World radial menu — right-click, 2 spokes v0.1 (Observe + Insert).
## Insert-styled: geometric lines, thin spokes, nearly transparent.
## Renders on InsertOverlay (CanvasLayer 10).
## Drag-release for power users, click-click for newcomers.
## Insert spoke sends Pause on activate, Pause again on close (toggle).
signal spoke_selected(spoke_name: String)
enum Spoke { NONE, OBSERVE, INSERT }
const SPOKE_RADIUS := 60.0
const INNER_RADIUS := 16.0
const LINE_COLOR := Color("#c8d0e0")
const LINE_DIM := Color(0.78, 0.82, 0.88, 0.3)
const HOVER_COLOR := Color("#e0e8ff")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.35)
const LINE_WIDTH := 1.5
const ICON_SIZE := 12.0
# Spoke angles: Observe = up (270°), Insert = down (90°)
const SPOKE_ANGLES := {
Spoke.OBSERVE: -PI / 2.0,
Spoke.INSERT: PI / 2.0,
}
const SPOKE_NAMES := {
Spoke.OBSERVE: "Observe",
Spoke.INSERT: "Insert",
}
var _open: bool = false
var _origin: Vector2 = Vector2.ZERO
var _hovered_spoke: int = Spoke.NONE
var _drag_mode: bool = false
var _insert_active: bool = false
func _ready() -> void:
var menu_extent := SPOKE_RADIUS + ICON_SIZE + 20.0 # spoke + icon + label margin
custom_minimum_size = Vector2(menu_extent * 2.0, menu_extent * 2.0)
size = custom_minimum_size
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_RIGHT:
if event.pressed and not _open:
_open_menu(event.global_position)
get_viewport().set_input_as_handled()
elif not event.pressed and _open and _drag_mode:
# Drag-release: select hovered spoke
_confirm_selection()
get_viewport().set_input_as_handled()
elif event.pressed and _open and not _drag_mode:
# Click-click: second click confirms
_confirm_selection()
get_viewport().set_input_as_handled()
if event is InputEventMouseMotion and _open:
_update_hover(event.global_position)
queue_redraw()
func _open_menu(pos: Vector2) -> void:
_open = true
_origin = pos
_hovered_spoke = Spoke.NONE
_drag_mode = true
visible = true
# Position the control so _origin is at center
global_position = _origin - size / 2.0
queue_redraw()
func _close_menu() -> void:
_open = false
_hovered_spoke = Spoke.NONE
_drag_mode = false
visible = false
func _update_hover(mouse_pos: Vector2) -> void:
var delta := mouse_pos - _origin
var dist := delta.length()
if dist < INNER_RADIUS:
_hovered_spoke = Spoke.NONE
_drag_mode = dist > 4.0 # Still dragging if moved at all
return
_drag_mode = true
var angle := delta.angle()
# Find closest spoke
var best_spoke: int = Spoke.NONE
var best_diff: float = PI # Max angular distance
for spoke in SPOKE_ANGLES:
var spoke_angle: float = SPOKE_ANGLES[spoke]
var diff := absf(angle_difference(angle, spoke_angle))
if diff < best_diff and diff < PI / 3.0: # 60° acceptance zone
best_diff = diff
best_spoke = spoke
_hovered_spoke = best_spoke
func _confirm_selection() -> void:
if _hovered_spoke != Spoke.NONE:
var name: String = SPOKE_NAMES.get(_hovered_spoke, "")
spoke_selected.emit(name)
if _hovered_spoke == Spoke.INSERT:
_activate_insert()
_close_menu()
func _activate_insert() -> void:
# TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol
if not _insert_active:
_insert_active = true
_send_pause()
func _send_pause() -> void:
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func deactivate_insert() -> void:
# Called when closing insert view — send Pause again (toggle)
if _insert_active:
_insert_active = false
_send_pause()
func _draw() -> void:
if not _open:
return
var center := size / 2.0
# Background circle
draw_circle(center, SPOKE_RADIUS + 8.0, BG_COLOR)
# Inner ring
draw_arc(center, INNER_RADIUS, 0, TAU, 32, LINE_DIM, 1.0)
# Outer ring
draw_arc(center, SPOKE_RADIUS, 0, TAU, 48, LINE_DIM, 1.0)
# Spokes
for spoke in SPOKE_ANGLES:
var angle: float = SPOKE_ANGLES[spoke]
var dir := Vector2(cos(angle), sin(angle))
var inner_pt := center + dir * INNER_RADIUS
var outer_pt := center + dir * SPOKE_RADIUS
var color := HOVER_COLOR if spoke == _hovered_spoke else LINE_COLOR
var width := LINE_WIDTH * 2.0 if spoke == _hovered_spoke else LINE_WIDTH
# Spoke line
draw_line(inner_pt, outer_pt, color, width)
# Icon at spoke tip
var icon_center := center + dir * (SPOKE_RADIUS + ICON_SIZE + 4.0)
_draw_spoke_icon(spoke, icon_center, color)
# Label
var label: String = SPOKE_NAMES.get(spoke, "")
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
match spoke:
Spoke.OBSERVE:
# Eye icon — simple geometric eye shape
var hw := ICON_SIZE * 0.7
var hh := ICON_SIZE * 0.4
# Eye outline (two arcs)
draw_arc(center - Vector2(0, hh * 0.3), hw, PI * 0.15, PI * 0.85, 12, color, 1.0)
draw_arc(center + Vector2(0, hh * 0.3), hw, -PI * 0.85, -PI * 0.15, 12, color, 1.0)
# Pupil
draw_circle(center, 2.5, color)
Spoke.INSERT:
# Phone/device icon — simple rectangle
var hw := ICON_SIZE * 0.35
var hh := ICON_SIZE * 0.55
draw_rect(Rect2(center - Vector2(hw, hh), Vector2(hw * 2, hh * 2)), color, false, 1.0)
# Screen line
draw_line(center - Vector2(hw * 0.6, hh * 0.3), center + Vector2(hw * 0.6, -hh * 0.3), color, 1.0)
# -- Public API ---------------------------------------------------------------
func is_open() -> bool:
return _open
func get_hovered_spoke() -> String:
return SPOKE_NAMES.get(_hovered_spoke, "")
func is_insert_active() -> bool:
return _insert_active
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/world_radial.gd" id="1_radial"]
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_radial")
+627
View File
@@ -0,0 +1,627 @@
#!/usr/bin/env python3
"""
Sprint CLI — orchestrates sprint lifecycle and context for agents.
Calls the ticket CLI for data queries (no SQL duplication).
Direct DB access only for sprint lifecycle mutations.
Usage:
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
sprint start [--sprint N] Activate a planned sprint
sprint stop [--sprint N] Complete an active sprint
sprint start-work [--sprint N] [--team T] Full context dump for starting work
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
"""
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
TICKET_CLI = str(SCRIPT_DIR / "ticket")
DB_PATH = (SCRIPT_DIR / ".." / ".." / ".." / "settledreach.db").resolve()
PROJECT_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
REMINDER = """---
Reminder: Keep ticket status up to date after finishing work.
db/connectors/ticket status <id> in_progress (when starting)
db/connectors/ticket status <id> done (when finished)"""
def run_ticket(*args):
"""Call the ticket CLI and return parsed JSON."""
result = subprocess.run(
[sys.executable, TICKET_CLI] + list(args),
capture_output=True, text=True
)
if result.returncode != 0:
return {"ok": False, "error": result.stderr.strip()}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"ok": False, "error": f"Bad ticket output: {result.stdout[:200]}"}
def get_connection():
"""Direct DB connection for lifecycle mutations only."""
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
conn.row_factory = sqlite3.Row
return conn
def parse_flags(args, known_flags):
"""Parse --flag value pairs from args, return (flags_dict, positional_args)."""
flags = {}
positional = []
i = 0
while i < len(args):
if args[i].startswith("--") and args[i][2:] in known_flags:
key = args[i][2:]
if i + 1 < len(args):
flags[key] = args[i + 1]
i += 2
else:
positional.append(args[i])
i += 1
else:
positional.append(args[i])
i += 1
return flags, positional
def detect_team(flags):
"""Detect team from flags or git branch."""
if "team" in flags:
return flags["team"]
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, cwd=str(PROJECT_ROOT)
)
branch = result.stdout.strip()
if branch and branch != "main":
return branch
except Exception:
pass
return None
def get_all_sprints():
"""Get all sprints via ticket CLI."""
data = run_ticket("sprint")
if not data.get("ok"):
return []
return data.get("sprints", [])
def detect_sprint(flags, prefer_status=None):
"""Detect sprint from flags or by status preference.
prefer_status: which status to prefer when auto-detecting.
'active' for status/start-work/stop
'planning' for start
None for prepare (targets next sprint)
"""
if "sprint" in flags:
sprint_id = int(flags["sprint"])
sprints = get_all_sprints()
for s in sprints:
if s["id"] == sprint_id:
return s
print(f"Error: Sprint {sprint_id} not found.")
sys.exit(1)
sprints = get_all_sprints()
if not sprints:
print("Error: No sprints found in database.")
sys.exit(1)
if prefer_status:
matching = [s for s in sprints if s["status"] == prefer_status]
if len(matching) == 1:
return matching[0]
if len(matching) > 1:
ids = ", ".join(str(s["id"]) for s in matching)
print(f"Error: Multiple {prefer_status} sprints: {ids}. Use --sprint N to specify.")
sys.exit(1)
# Fall through: no match for preferred status
if prefer_status == "active":
# No active sprint
print("Error: No active sprint. Use --sprint N to specify.")
sys.exit(1)
if prefer_status == "planning":
print("Error: No sprint in planning status. Use sprint prepare first.")
sys.exit(1)
return None
def detect_sprint_for_prepare(flags):
"""For prepare: target the next sprint after the most recent one."""
if "sprint" in flags:
sprint_id = int(flags["sprint"])
sprints = get_all_sprints()
for s in sprints:
if s["id"] == sprint_id:
return s
# Sprint doesn't exist yet — return a stub
return {"id": sprint_id, "status": "new", "name": None}
sprints = get_all_sprints()
# If there's a planning sprint, use it
planning = [s for s in sprints if s["status"] == "planning"]
if len(planning) == 1:
return planning[0]
if len(planning) > 1:
ids = ", ".join(str(s["id"]) for s in planning)
print(f"Error: Multiple planning sprints: {ids}. Use --sprint N to specify.")
sys.exit(1)
# Otherwise target max_id + 1
if sprints:
next_id = max(s["id"] for s in sprints) + 1
return {"id": next_id, "status": "new", "name": None}
return {"id": 1, "status": "new", "name": None}
def get_tickets_for_sprint(sprint_id, team=None):
"""Get tickets for a sprint, optionally filtered by team."""
args = ["list", "--sprint", str(sprint_id)]
if team:
args += ["--team", team]
data = run_ticket(*args)
if not data.get("ok"):
return []
return data.get("rows", [])
def get_ticket_deps(ticket_id):
"""Get dependencies for a ticket."""
data = run_ticket("deps", str(ticket_id))
if not data.get("ok"):
return {"blocked_by": [], "blocks": []}
return data
def get_ticket_detail(ticket_id):
"""Get full ticket detail."""
data = run_ticket("show", str(ticket_id))
if not data.get("ok"):
return None
return data.get("ticket")
def briefing_path(sprint_id, team):
"""Find the briefing file for a sprint/team if it exists."""
p = PROJECT_ROOT / "docs" / "sprints" / f"sprint-{sprint_id}" / f"{team}.md"
if p.exists():
return str(p.relative_to(PROJECT_ROOT))
return None
def format_ticket_table(tickets):
"""Format tickets as an aligned table."""
if not tickets:
print(" (none)")
return
# Header
print(f" {'#':<6} {'Title':<50} {'Status':<12} {'Assigned':<10} {'Priority'}")
print(f" {'---':<6} {'---':<50} {'---':<12} {'---':<10} {'---'}")
for t in tickets:
title = t.get("title", "")
if len(title) > 48:
title = title[:45] + "..."
assigned = t.get("assigned_to") or ""
print(f" {t['id']:<6} {title:<50} {t['status']:<12} {assigned:<10} {t['priority']}")
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_status(args):
flags, _ = parse_flags(args, ["sprint", "team"])
sprint = detect_sprint(flags, prefer_status="active")
team = detect_team(flags)
tickets = get_tickets_for_sprint(sprint["id"], team)
# Header
name = sprint.get("name", f"Sprint {sprint['id']}")
print(f"=== {name} ({sprint['status']}) ===")
if sprint.get("goal"):
print(f"Goal: {sprint['goal']}")
parts = []
if sprint.get("start_date"):
parts.append(f"Started: {sprint['start_date']}")
if sprint.get("end_date"):
parts.append(f"Ended: {sprint['end_date']}")
if team:
parts.append(f"Team: {team}")
if parts:
print(" | ".join(parts))
print()
# Progress
total = len(tickets)
done = sum(1 for t in tickets if t["status"] == "done")
pct = int(done / total * 100) if total > 0 else 0
print(f"Progress: {done}/{total} done ({pct}%)")
# Status breakdown
statuses = {}
for t in tickets:
statuses[t["status"]] = statuses.get(t["status"], 0) + 1
status_parts = []
for s in ["backlog", "ready", "in_progress", "review", "done", "cancelled"]:
if s in statuses:
status_parts.append(f"{s}: {statuses[s]}")
if status_parts:
print(f" {' | '.join(status_parts)}")
print()
# Ticket table
print("Tickets:")
format_ticket_table(tickets)
print()
# Blocked tickets
blocked_lines = []
for t in tickets:
if t["status"] == "done":
continue
deps = get_ticket_deps(t["id"])
for b in deps.get("blocked_by", []):
if b["status"] != "done":
blocked_lines.append(f" #{t['id']} blocked by #{b['id']} ({b['status']})")
if blocked_lines:
print("Blocked:")
for line in blocked_lines:
print(line)
print()
# Briefing
if team:
bp = briefing_path(sprint["id"], team)
if bp:
print(f"Briefing: {bp}")
else:
# Show all available briefings
briefings = []
for t_name in ["server", "client", "copy", "audio", "visual", "ci", "joint"]:
bp = briefing_path(sprint["id"], t_name)
if bp:
briefings.append(bp)
if briefings:
print("Briefings:")
for bp in briefings:
print(f" {bp}")
print()
print(REMINDER)
def cmd_start(args):
flags, _ = parse_flags(args, ["sprint"])
sprint = detect_sprint(flags, prefer_status="planning")
if sprint["status"] != "planning":
print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'planning'.")
sys.exit(1)
# Check ticket count
tickets = get_tickets_for_sprint(sprint["id"])
if not tickets:
print(f"Error: Sprint {sprint['id']} has no tickets. Run sprint prepare first.")
sys.exit(1)
# Activate
conn = get_connection()
conn.execute(
"UPDATE sprints SET status='active', start_date=date('now') WHERE id=?",
(sprint["id"],)
)
conn.commit()
conn.close()
# Summary
teams = {}
for t in tickets:
team = t.get("team") or "unassigned"
teams[team] = teams.get(team, 0) + 1
name = sprint.get("name", f"Sprint {sprint['id']}")
print(f"Started: {name}")
print(f"Tickets: {len(tickets)}")
for team, count in sorted(teams.items()):
print(f" {team}: {count}")
print()
print(REMINDER)
def cmd_stop(args):
flags, _ = parse_flags(args, ["sprint"])
sprint = detect_sprint(flags, prefer_status="active")
if sprint["status"] != "active":
print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'active'.")
sys.exit(1)
tickets = get_tickets_for_sprint(sprint["id"])
done = [t for t in tickets if t["status"] == "done"]
cancelled = [t for t in tickets if t["status"] == "cancelled"]
incomplete = [t for t in tickets if t["status"] not in ("done", "cancelled")]
# Complete
conn = get_connection()
conn.execute(
"UPDATE sprints SET status='completed', end_date=date('now') WHERE id=?",
(sprint["id"],)
)
conn.commit()
conn.close()
name = sprint.get("name", f"Sprint {sprint['id']}")
print(f"Completed: {name}")
print(f"Done: {len(done)}/{len(tickets)}")
if cancelled:
print(f"Cancelled: {len(cancelled)}")
print()
if incomplete:
print("Carry-over candidates (incomplete):")
format_ticket_table(incomplete)
print()
print(REMINDER)
def cmd_start_work(args):
flags, _ = parse_flags(args, ["sprint", "team"])
sprint = detect_sprint(flags, prefer_status="active")
team = detect_team(flags)
if sprint["status"] != "active":
print(f"Error: Sprint {sprint['id']} is '{sprint['status']}', expected 'active'.")
sys.exit(1)
tickets = get_tickets_for_sprint(sprint["id"], team)
# Header
name = sprint.get("name", f"Sprint {sprint['id']}")
team_label = f" \u2014 {team.title()}" if team else ""
print(f"=== {name}{team_label} ===")
if sprint.get("goal"):
print(f"Goal: {sprint['goal']}")
parts = [f"Status: {sprint['status']}"]
if sprint.get("start_date"):
parts.append(f"Started: {sprint['start_date']}")
print(" | ".join(parts))
print()
# Briefing
if team:
bp = briefing_path(sprint["id"], team)
if bp:
print(f"Briefing: {bp}")
# Also check joint briefing
jbp = briefing_path(sprint["id"], "joint")
if jbp:
print(f"Joint briefing: {jbp}")
# Collect decision refs
decision_refs = set()
for t in tickets:
detail = get_ticket_detail(t["id"])
if detail and detail.get("decision_ref"):
decision_refs.add(detail["decision_ref"])
if decision_refs:
print(f"Decisions: {', '.join(sorted(decision_refs))}")
print()
# Build dependency map
blocked_by_map = {} # ticket_id -> [blocker tickets]
blocks_map = {} # ticket_id -> [blocked ticket ids]
for t in tickets:
deps = get_ticket_deps(t["id"])
open_blockers = [b for b in deps.get("blocked_by", []) if b["status"] != "done"]
if open_blockers:
blocked_by_map[t["id"]] = open_blockers
blocking = deps.get("blocks", [])
if blocking:
blocks_map[t["id"]] = blocking
# Categorize
done_tickets = [t for t in tickets if t["status"] == "done"]
blocked_tickets = [t for t in tickets if t["status"] != "done" and t["id"] in blocked_by_map]
actionable_tickets = [t for t in tickets if t["status"] != "done" and t["id"] not in blocked_by_map]
# Actionable
if actionable_tickets:
print("Actionable (not blocked, not done):")
for t in actionable_tickets:
print(f" #{t['id']}: {t['title']}")
# Metadata line
meta = [t.get("type", ""), f"P:{t['priority']}", f"S:{t['status']}"]
if t.get("assigned_to"):
meta.append(f"@{t['assigned_to']}")
if t.get("team"):
meta.append(f"Team:{t['team']}")
detail = get_ticket_detail(t["id"])
if detail and detail.get("decision_ref"):
meta.append(f"Ref:{detail['decision_ref']}")
print(f" {' | '.join(meta)}")
if t["id"] in blocks_map:
block_ids = ", ".join(f"#{b['id']}" for b in blocks_map[t["id"]])
print(f" Blocks: {block_ids}")
print()
# Blocked
if blocked_tickets:
print("Blocked:")
for t in blocked_tickets:
blockers = blocked_by_map[t["id"]]
blocker_str = ", ".join(f"#{b['id']} ({b['status']})" for b in blockers)
print(f" #{t['id']}: {t['title']} \u2190 blocked by {blocker_str}")
print()
# Done
if done_tickets:
print("Done:")
for t in done_tickets:
print(f" #{t['id']}: {t['title']} \u2713")
print()
print(REMINDER)
def cmd_prepare(args):
flags, _ = parse_flags(args, ["sprint", "team"])
sprint = detect_sprint_for_prepare(flags)
team = detect_team(flags)
# Create sprint record if it doesn't exist
if sprint.get("status") == "new":
conn = get_connection()
conn.execute(
"INSERT INTO sprints (id, name, status) VALUES (?, ?, 'planning')",
(sprint["id"], f"Sprint {sprint['id']}")
)
conn.commit()
conn.close()
print(f"Created Sprint {sprint['id']} (planning)")
sprint["status"] = "planning"
sprint["name"] = f"Sprint {sprint['id']}"
elif sprint["status"] not in ("planning", "new"):
print(f"Warning: Sprint {sprint['id']} is '{sprint['status']}', not 'planning'.")
print(f"=== Preparing Sprint {sprint['id']} ===")
print()
# Previous sprint info
all_sprints = get_all_sprints()
prev_sprints = [s for s in all_sprints if s["id"] < sprint["id"]]
if prev_sprints:
prev = max(prev_sprints, key=lambda s: s["id"])
prev_tickets = get_tickets_for_sprint(prev["id"])
prev_done = sum(1 for t in prev_tickets if t["status"] == "done")
prev_name = prev.get("name", f"Sprint {prev['id']}")
print(f"Previous: {prev_name} ({prev['status']}, {prev_done}/{len(prev_tickets)} done)")
print()
# Carry-over candidates
incomplete = [t for t in prev_tickets if t["status"] not in ("done", "cancelled")]
if team:
incomplete = [t for t in incomplete if team in (t.get("team") or "")]
if incomplete:
print("Carry-over candidates (incomplete from previous sprint):")
format_ticket_table(incomplete)
print()
# Backlog candidates
backlog_args = ["list", "--status", "backlog"]
if team:
backlog_args += ["--team", team]
backlog_data = run_ticket(*backlog_args)
backlog = backlog_data.get("rows", []) if backlog_data.get("ok") else []
# Filter out tickets already assigned to a sprint
backlog = [t for t in backlog if not t.get("sprint_id")]
if backlog:
if team:
print(f"Backlog candidates ({team}):")
format_ticket_table(backlog)
else:
# Group by team
by_team = {}
for t in backlog:
t_team = t.get("team") or "unassigned"
by_team.setdefault(t_team, []).append(t)
print("Backlog candidates (unassigned to any sprint):")
for t_name in sorted(by_team.keys()):
print(f"\n {t_name.title()}:")
format_ticket_table(by_team[t_name])
print()
# Decision coverage gaps
conn = get_connection()
cursor = conn.execute("""
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)
ORDER BY id
""")
orphans = cursor.fetchall()
conn.close()
if orphans:
print("Decision coverage gaps (active decisions without tickets):")
for row in orphans:
print(f" {row[0]}: {row[1]}")
print()
# Already assigned to this sprint
assigned = get_tickets_for_sprint(sprint["id"], team)
if assigned:
print(f"Already assigned to Sprint {sprint['id']}:")
format_ticket_table(assigned)
print()
print(REMINDER)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
HELP = """sprint \u2014 sprint lifecycle and context for agents
Usage:
sprint status [--sprint N] [--team T] Sprint progress and ticket overview
sprint start [--sprint N] Activate a planned sprint
sprint stop [--sprint N] Complete an active sprint
sprint start-work [--sprint N] [--team T] Full context dump for starting work
sprint prepare [--sprint N] [--team T] Prepare next sprint (candidates + gaps)
Sprint auto-detection:
status/start-work prefer the active sprint
start prefer the planning sprint
stop prefer the active sprint
prepare target next sprint (max id + 1)
Team auto-detection:
If --team is omitted, uses the current git branch name (unless on main).
On main with no --team, shows all teams."""
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h", "help"):
print(HELP)
sys.exit(0)
cmd = sys.argv[1]
args = sys.argv[2:]
commands = {
"status": cmd_status,
"start": cmd_start,
"stop": cmd_stop,
"start-work": cmd_start_work,
"prepare": cmd_prepare,
}
if cmd not in commands:
print(f"Error: Unknown command '{cmd}'. Use --help for usage.")
sys.exit(1)
commands[cmd](args)
if __name__ == "__main__":
main()
+5 -5
View File
@@ -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 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033 |
| [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 |
| [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 |
| [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 |
| [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 |
| [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 |
| [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 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-017 |
| [questions.md](questions.md) | Open questions | Q-001 through Q-026 |
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
## Querying Decisions
+39 -1
View File
@@ -140,6 +140,44 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Raised by:** Team decision in Sprint 5 planning
- **Dissent:** None
### D-054: Tile-based movement with same-tile occupancy
- **Date:** 2026-02-13
- **Decision:** All movement is tile-based (server-authoritative, discrete positions). Client-side Tween interpolation (100-150ms) hides the grid visually. Same-tile occupancy via TilePresence component (Standing/Prone/Seated/Fixture layers) allows multiple entities on one tile in different postures. Mouse facing is a client-side float; the server receives the facing octant only. Tile occupancy provides trivial collision detection.
- **Rationale:** Determinism ([D-010](#d-010-multiplayer-ready-architectural-baseline) principle 4). Tile-based enables shadowcasting ([D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation)), pathfinding, chunk-based maps ([D-012](#d-012-chunk-based-map-architecture-for-future-borderless-generation)), and trivial collision. Occupancy system adds positioning depth (doorway blocking, eavesdrop positioning, sitting at furniture) within tile-based constraints. ~150 lines server-side.
- **Implementation:** TilePresence enum: Standing, Prone, Seated, Fixture. Multiple entities can share a tile if they occupy different posture layers.
- **Cross-reference:** Stance system ([D-053](scope.md#d-053-movement-as-stance-toggle-system)), shadowcasting ([D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Tyre (tile-based, non-negotiable), Dudley (tiles-per-tick model), Nigel (converted in Round 2: "tiles are BETTER for replayability — discrete positions = finite meaningful choices")
- **Dissent:** Nigel initially proposed free movement with tile-based collision (Round 1). Converted in Round 2 after demonstrating that tile-based spatial puzzles (doorway decisions, corner peeks, eavesdrop corridors) create replayability.
### D-055: Sprint explicitly suppresses interaction buffer
- **Date:** 2026-02-13
- **Decision:** When in Sprint stance ([D-053](scope.md#d-053-movement-as-stance-toggle-system)), the server explicitly clears the interaction buffer. No interaction verbs are computed or sent to the client during sprint. Anomaly monologue survives sprint — the "sprint double-take" (if the character passes something anomalous while sprinting, a delayed monologue fires retroactively: "Wait — was that Kael? At this hour?").
- **Rationale:** Mouse gymnastics to click during sprint = bad UX. Explicit suppression is cleaner and deterministic. The sprint double-take preserves the feel that the character is still aware even when the player can't interact — sprint suppresses interpretation (monologue at 40%), not sensory data (overlays still render).
- **Cross-reference:** Stance system ([D-053](scope.md#d-053-movement-as-stance-toggle-system)), monologue ([D-016](perception.md#d-016-internal-monologue-as-core-perceptionatmosphere-system))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Dudley (explicit suppression), Ozzie (anomaly survival / double-take), Gestalt (interpretation vs data framing)
- **Dissent:** Gestalt argued physics handles it naturally (player passes through interaction radius too fast to click). Lead ruled explicit suppression for clarity and determinism.
### D-066: Dual-scale grid — 0.5m simulation, 1m visual (2x retina factor)
- **Date:** 2026-02-14
- **Decision:** The game uses two coordinate scales with a fixed 2x retina factor:
- **Simulation grid: 0.5m tiles.** All movement, LOS/shadowcasting ([D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation)), pathfinding, occupancy ([D-054](#d-054-tile-based-movement-with-same-tile-occupancy)), and interaction range operate at 0.5m per sim tile. The server knows only sim tiles.
- **Visual grid: 1m tiles.** The Godot client renders floor art, wall art, and structural tiles as 2x2 blocks of sim tiles. Art is authored at 1m conceptual scale.
- **World geometry: 2x2 sim tile minimum.** All walls, furniture, crates, doors, and environmental objects occupy a minimum of 2x2 sim tiles (= 1 visual tile). This ensures visual truth and sim truth agree on where solid things are — cover, LOS occlusion, and collision map 1:1 with what the player sees.
- **Entities: 1x1 sim tiles.** Characters and small items occupy individual 0.5m sim tiles, giving sub-visual-tile positioning precision. Entities naturally take corners/edges within a visual tile's 1m space.
- **Sprites: 2x2 sim tile footprint.** Entity sprites render across 2x2 sim tiles so they feel proportional to the 1m visual grid. Tween interpolation ([D-054](#d-054-tile-based-movement-with-same-tile-occupancy)) hides half-visual-tile movement increments.
- **Mental model:** "Objects are where they look. I can position myself precisely within open space." The player reads cover and walls at visual scale (always correct). Fine movement granularity is felt, not counted.
- **What the simulation does NOT know:** Visual tiles. The retina factor is purely a client rendering convention. The server operates exclusively on 0.5m sim tiles.
- **Fog shader ([D-059](perception.md#d-059-fog--shader-based-five-layers-knowledge-graph-driven)):** Unaffected — fog is screen-space, driven by PointLight2D vision cone and LOS mask from sim-resolution shadowcasting. Gradient edge "3-4 tiles" is retuned to 6-8 sim tiles (= 3-4 visual tiles) to preserve the intended softness.
- **Cursor/interaction:** No change — cursor already resolves to sim tile from pixel position. Interaction range of ~2 sim tiles = 1m (arm's length).
- **Map authoring:** Author at 1m visual scale. Subdivision tool expands each visual tile to 4 sim tiles (2x2). Validation enforces 2x2 minimum on all world geometry layers.
- **Amends:** OQ-01 resolution (ticket #444). Sim tile size remains 0.5m; visual presentation changes from 1:1 to 2:1 retina factor.
- **Cross-reference:** Tile-based movement ([D-054](#d-054-tile-based-movement-with-same-tile-occupancy)), shadowcasting ([D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation)), fog ([D-059](perception.md#d-059-fog--shader-based-five-layers-knowledge-graph-driven)), art direction ([D-043](perception.md#d-043-art-direction--visual-style-functional-warmth)), z-stack ([D-049](perception.md#d-049-z-level-rendering-stack-8-layers)), stance system ([D-053](scope.md#d-053-movement-as-stance-toggle-system))
- **Rationale:** 0.5m sim tiles give stealth-grade granularity for movement stances, cover peeking, and interaction range. 1m visual tiles make spaces feel proportional, sprites look right, and world geometry readable. The 2x2 minimum on geometry eliminates visual/sim mismatch for cover and LOS — the only sub-visual-tile positioning is entity movement, which is communicated through fog feedback, not tile counting. Analogous to macOS Retina: logical resolution (visual) differs from physical resolution (sim), but the system is coherent because both agree on where solid objects are.
- **Raised by:** Team Leader (Jeroen) — proposed retina scaling analogy and 2x2 geometry constraint. Tyre (feasibility: trivial, half-day integration). Gestalt (approved with 2x2 constraint resolving LOS readability concern). Ozzie (approved: solves sprite scale without uncanny mismatch).
- **Dissent:** Gestalt initially objected to dual-scale (mental model mismatch for cover/LOS). Resolved by the 2x2 geometry minimum constraint — all cover maps 1:1 at visual scale.
---
*10 decisions. Last updated: 2026-02-13*
*13 decisions. Last updated: 2026-02-14*
+39 -1
View File
@@ -104,6 +104,44 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
- **Raised by:** Miri (Round 1 proposal, Round 2 validation), Paula (moral dimension endorsement), full team validated
- **Dissent:** None
### D-050: Velen — Krenn System primary world
- **Date:** 2026-02-12
- **Decision:** Velen is the canonical name for the Krenn System's primary habitable world. Temperate-maritime climate, ~0.9G, regular rain, morning/evening fog, mild temperature range, occasional heavy squalls. Station Sova orbits Velen. The span gate connects Sova to a planetary freight depot on Velen's surface. Naming convention: compact, consonant-weighted, two-syllable, Nordic-influenced.
- **Weather as gameplay (cross-ref D-046):** Fog degrades everyone's vision cones equally — shared vulnerability. The storyteller can time weather for dramatic effect without breaking environmental neutrality (D-045). Foggy mornings = shorter cones = smuggler's early drops safer, detective's dawn surveillance harder.
- **Cross-reference:** Setting ([D-036](#d-036-sova-transit-district--krenn-system-as-v01-setting))
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.13, §1.15
- **Raised by:** Miri (Round 2 proposal), confirmed by project lead (Round 3). Unanimously endorsed.
- **Dissent:** None
### D-062: Invisible locked dialogue options
- **Date:** 2026-02-13
- **Decision:** Dialogue options the player hasn't unlocked are completely invisible. No grayed-out options. No lock icons. No hint that more options exist. The player doesn't know what they don't know. Exception: NPC holding back is communicated via monologue ("She changed the subject. Fast."), not via locked UI elements.
- **Rationale:** Four reasons: (1) Asymmetry — you don't know what you don't know. (2) Dopamine — new options appearing on repeat visits IS the reward. (3) Anti-metagaming — no checklist to complete. (4) Confrontation surprise — Confront option appearing for the first time is a dramatic moment. Strongest consensus point of the entire workshop — unanimous across all 8 participants.
- **Cross-reference:** Dialogue architecture ([D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers)), knowledge graph confidence tiers ([D-041](architecture.md#d-041-knowledge-graph-data-model)), dialogue box ([D-061](perception.md#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Gestalt (proposal + four-reason rationale), Nigel (emphatic reinforcement: "non-negotiable")
- **Dissent:** None. Unanimous.
### D-063: Confrontation — same box, different weight
- **Date:** 2026-02-13
- **Decision:** Confrontation uses the same dialogue UI as casual conversation — no separate confrontation mode. Different weight communicated through four mechanisms: (1) Confrontation options written in character's internal voice — italicized, first-person (regular: "Shift schedule" / confrontation: *"I saw you in corridor B-7"*). (2) Pre-delivery monologue beat (1-2 seconds: *"This changes things. No taking it back."*) — character hesitates internally before speaking. (3) World responds — NPC shifts to Tier 2 animation ([D-047](perception.md#d-047-art-direction--two-tier-animation-system)), entity D-033 color may fade, monologue frequency spikes, available topics narrow post-confrontation. (4) Walk-away mid-confrontation contaminates social space — NPC routine may shift, KG records incompleteness.
- **v0.1 staging:** Proximity check + audio dip + text styling. Camera tighten deferred to post-v0.1.
- **Explicitly NOT:** Separate confrontation UI, timed responses, visible relationship meter, correct/incorrect dialogue approaches.
- **Rationale:** Confrontation should feel heavy because of what you're saying, not because the UI changed. The weight comes from pacing, voice, and consequence.
- **Cross-reference:** Dialogue architecture ([D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers)), two-tier animation ([D-047](perception.md#d-047-art-direction--two-tier-animation-system)), entity color ([D-033](perception.md#d-033-entity-color--relationship-to-player)), walk-away ([D-064](#d-064-walk-away--three-phase-consequences))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Paula (four mechanisms), Ozzie (physical staging), Stig (UI container)
- **Dissent:** None.
### D-064: Walk-away — three-phase consequences
- **Date:** 2026-02-13
- **Decision:** Walking away from dialogue (WASD during conversation) triggers three distinct phases: (1) Immediate break — dialogue fades over 300ms, silence. No close button. (2) NPC reacts — animation shifts, may call after player, routine may change. (3) KG records incompleteness — the knowledge graph logs that the interaction was initiated but not completed. This is queryable and affects future dialogue, monologue, and NPC behavior. Walk-away consequences vary by NPC tolerance threshold per seed — no universal social rules to metagame.
- **Rationale:** Walking away is an action with meaning. Leaving mid-confrontation is different from leaving mid-smalltalk. The KG recording means the game remembers what you started. Per-seed tolerance prevents players from learning universal "safe to walk away" rules across playthroughs.
- **Cross-reference:** Confrontation ([D-063](#d-063-confrontation--same-box-different-weight)), knowledge graph ([D-041](architecture.md#d-041-knowledge-graph-data-model)), dialogue box ([D-061](perception.md#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Paula (three phases + KG recording), Stig (WASD mechanic + 300ms fade), Ozzie (consequences), Nigel (tolerance per seed)
- **Dissent:** None.
---
*10 decisions. Last updated: 2026-02-11*
*14 decisions. Last updated: 2026-02-13*
+145 -2
View File
@@ -84,7 +84,8 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- Camera change to 3D IS the dramatic signal - player knows something significant is happening (Gestalt)
- **Architecture note (Tyre):** Client-server separation means the renderer is swappable. A full first-person client is architecturally possible in the future. Top-down now doesn't mean top-down forever.
- **v0.1:** Top-down only. No cutscenes. Those are milestone features.
- **Raised by:** Team Leader (Jeroen), after full team review of tradeoffs in Round 12.
- **Amendment (2026-02-12, Art Direction Workshop):** Camera angle specified as **~15-20° from vertical** ("the angle"), rendered in sprite art via orthographic camera. Sprites are drawn as if viewed from a shallow tilt (south-facing front faces visible on objects, entities, and walls), but the Godot camera is purely orthographic — the perspective is an art convention, not a camera setting. Tile grid remains square/orthogonal (64x64). Vision cone math remains pure 2D. 3D render pipeline uses Camera3D at -72.5° from horizontal (midpoint of range) to produce sprites with mathematically correct perspective. Matches Rimworld's approach: orthographic camera, tilt faked entirely in art. Internally referred to as "the angle."
- **Raised by:** Team Leader (Jeroen), after full team review of tradeoffs in Round 12. Angle amendment: Team Leader, endorsed unanimously in Art Direction Workshop Round 3.
### D-033: Entity color = relationship to player
- **Date:** 2026-02-11
@@ -130,6 +131,148 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Raised by:** Dudley (implementation + benchmark), Tyre (technical direction)
- **Dissent:** None
### D-043: Art direction — visual style ("functional warmth")
- **Date:** 2026-02-12
- **Decision:** Clean 2D with bold silhouettes, tile-based world composition (64x64px visual tiles on dual-scale grid per [D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)), lighting-driven atmosphere. Not pixel art, not painted, not 3D. Godot 4 Light2D pipeline (PointLight2D per fixture, LightOccluder2D on walls, CanvasModulate for global ambient). AI-generated assets via Nano Banana / Gemini 2.5 Flash, rendered through 3D pipeline for perspective consistency. Core production principle: "Sprites are shape templates that the lighting system completes" — no baked shadows, no baked lighting, no baked mood.
- **Label:** "Functional warmth" (Gore).
- **Resolution chain:** 1024x1024 source → 256x256 working (outlines applied at 4-8px) → 64x64 runtime. Bilinear interpolation both passes. Outline color: dark blue-grey `#333340`.
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.1, §9.1-9.3
- **Raised by:** Araminta (lead), unanimously endorsed. Art Direction & Mood Board Workshop (3 rounds + closing).
- **Dissent:** None. All four agents independently converged on the same style.
### D-044: Art direction — visual hierarchy (entity > object > structure)
- **Date:** 2026-02-12
- **Decision:** Three-layer visual hierarchy: entities (2px outline, D-033 relationship color, highest saturation) > objects (1px outline, era-appropriate palette, moderate saturation) > structure (minimal/no outline, muted zone palette, lowest saturation). Hard rendering rule: entity always wins visual ties — if an entity and object overlap, the entity's D-033 color must remain visible. This is a readability guarantee, not an aesthetic preference.
- **Entity spec:** 24x32 pixel footprint within 64x64px visual tiles. Entities occupy 1x1 sim tiles (0.5m) but render across a 2x2 sim tile sprite footprint per [D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor). D-033 color as primary information signal, silhouette as primary identity signal. One identifying silhouette feature per named NPC (Kael's vest, Lera's apron, Sera's uniform). 3-4 template silhouettes for generic NPCs.
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.4, §1.5
- **Raised by:** Araminta (outline spec), Ozzie (readability priority). Unanimously endorsed.
- **Dissent:** None
### D-045: Art direction — environmental neutrality (strict zero shift)
- **Date:** 2026-02-12
- **Decision:** The base world layer never shifts in response to conspiracy activation or investigation state. The rendering pipeline never modifies world-layer visuals in response to narrative state. Zone lighting is fixed. What CANNOT change: light color temperature, shadow depth/direction, tile colors, wall tones, floor patterns, any CanvasModulate shift correlated with narrative state. What CAN change: insert overlay density, entity colors (D-033), monologue frequency/urgency, player character sprite posture. Allowed diegetic changes (everyone experiences them): time-of-day cycle (D-031), weather (physical reality, storyteller can time it).
- **Key insight (Gore):** "The player walks into the bar after discovering the conspiracy and it's STILL warm and inviting. That's the horror. The warm light isn't ironic — it's indifferent."
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.10
- **Raised by:** Gore and Miri (strict zero position). Araminta and Ozzie moved to this position in Round 2 — convergence through persuasion, not compromise.
- **Dissent:** None
### D-046: Art direction — lighting system (three-reference model)
- **Date:** 2026-02-12
- **Decision:** Three-reference lighting: (1) Darkwood — vision cone mechanics, light pooling, graduated fog boundary, darkness-as-weight. Retuned: darkness = uncertainty (not hostility), light = visibility (not safety), beyond the cone = life continuing without you (not monsters). (2) Blade Runner 2049 / Deakins — color temperature as emotional language: warm amber = social/inhabited, cool white = institutional/official, mixed = transitional. (3) Edward Hopper (Nighthawks) — warm interior surrounded by unknowable dark. The game's central image. Emotional register: uncertainty, not dread. Character temperature via spatial paths: smuggler's routine through warm-lit spaces (dock, bar), detective's routine through cool-lit spaces (Commission kiosk, corridors). Same fixtures, different daily routes — no rendering tricks.
- **Godot implementation:** PointLight2D (per fixture, per zone) + LightOccluder2D (on walls/obstacles) + CanvasModulate (global ambient) + textured PointLight2D on player (vision cone shape). Stock Godot 4 pipeline.
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.6, §1.11
- **Raised by:** Ozzie (Darkwood), Araminta (BR2049 color), Gore (Hopper), Miri (character spatial paths). Unanimously endorsed.
- **Dissent:** None
### D-047: Art direction — two-tier animation system
- **Date:** 2026-02-12
- **Decision:** Tier 1 (clear): public daily activities — walking/running (4-6 directional frames), working at terminal/handling cargo (2-3 states), eating/drinking (2-3 states), talking (2-3 states, distinguishable from "standing near"), sleeping (1 state). Instantly readable. Tier 2 (ambiguous): privately motivated behaviors — pausing, looking around, lingering near a location, changing direction, proximity without clear interaction. Player sees the action but cannot determine the intention. The boundary between tiers is invisible to the player.
- **Key insight:** "The clear/ambiguous division isn't an art decision — it's the investigation mechanic expressed through animation" (Ozzie). Routine must be readable so deviations are noticeable. Maps to NPC tell system (D-024).
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.12
- **Raised by:** Ozzie and Gore (two-tier proposal), Araminta (production spec). Unanimously endorsed.
- **Dissent:** None
### D-048: Neural insert overlay — visual design
- **Date:** 2026-02-12
- **Decision:** Geometric data layer (precise positioning, clean lines, structured information) rendered with soft bloom shader pass (~2-3px gaussian blur at ~40% blend on the insert CanvasLayer). D-033 colors gain soft halos rather than hard edges. Smuggler's overlay: thinner, sparser (baseline lattice hardware). Detective's overlay: denser, crisper (augmented lattice hardware). Passive state nearly invisible; active state clean and precise. Insert overlay is NOT affected by the fog shader — insert data is computational, not perceptual. Insert markers can appear in fogged areas if the lattice has that data.
- **Tests:** "If switching the overlay OFF would feel like going deaf rather than closing a window, it's working" (Gore). "After 10 minutes, does the player forget the insert is there? If yes, we've succeeded" (Ozzie).
- **Cross-reference:** Diegetic insert ([D-013](scope.md#d-013-diegetic-insertpoi-navigation-system)), perception modes ([D-017](#d-017-perception-modes-as-character-build-system))
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.9
- **Raised by:** Araminta (geometric spec), Gore (organic requirement). Resolves Round 1 divergence — both survive in synthesis.
- **Dissent:** None
### D-049: Z-level rendering stack (8 layers)
- **Date:** 2026-02-12
- **Decision:** 8-layer z-stack: (0) Floor tiles — zone identity, movement surface. (1) Floor objects — cosmetic detail, walked over. (2) Furniture/placed objects — y-sorted with entities, visual complexity center. (3) Entity sprites — y-sorted with Layer 2, D-033 colored. (4) Overhead/wall tops — pipes, ducts, lighting fixtures, signage; semi-transparent partial occlusion. (5) Fog of perception — vision cone mask, fog shader; affects layers 0-4. (6) Insert overlay — lattice HUD elements, bloom-rendered, NOT affected by fog. (7) Monologue/UI — top of stack, always visible. Wall rendering: Option B (visible top + face) for structural walls, Option A (boundary lines) for interior partitions. Sprite stacking out of scope.
- **Key insight (Gore):** Overhead occlusion is thematically significant — entities passing behind shelving/under overhangs create local information gaps within otherwise known spaces.
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.14
- **Raised by:** Araminta (8-layer spec), Gore (overhead as thematic tool). Unanimously endorsed.
- **Dissent:** None
### D-052: Character favorite colors — object-layer identification
- **Date:** 2026-02-12
- **Decision:** Each NPC has a favorite color expressed through personal objects (bedding, cushions, mugs, personal items), NOT on entity sprites. Muted register: dusty blue, warm terracotta, faded olive — personal, not faction. Creates secondary identification system: D-033 = relationship to player (entity layer), favorite color = person's identity in space (object layer). Investigation mechanic: recognizing whose stuff is where. Saturation constraint: favorite color saturation must stay below D-033 entity color saturation to preserve visual hierarchy. Godot implementation: mask shader + material duplication per unique color (~0.5-1ms at 50-100 objects). Build for v0.1.2+.
- **Cross-reference:** Visual hierarchy ([D-044](#d-044-art-direction--visual-hierarchy-entity--object--structure)), design principle ([D-051](scope.md#d-051-settling-is-placement--design-principle))
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §7 (D-051 candidate)
- **Raised by:** Team Leader (Jeroen), unanimously endorsed.
- **Dissent:** None
### D-056: Cursor states — insert-styled geometric
- **Date:** 2026-02-13
- **Decision:** Four cursor states using the neural insert's geometric visual language:
- **Default:** Four thin inward-pointing ticks with bloom. White-blue #c8d0e0. Barely visible — the insert's own cursor.
- **Entity hover:** Ticks expand outward (150ms). Corner brackets frame entity. Color shifts to D-033 relationship color. Verb tooltip in insert styling. Bloom pulse ~10% brighter on entity outline.
- **Object hover:** Ticks rotate 45° to X-shape. Muted grey #8b8ba0 (amber #e8c547 if flagged). Simpler frame than entity.
- **Weapon aim:** Hard transition. Ticks extend, center gap widens, lines thicken 1→2px. Warm white #f0e8d8. NO bloom. Entity in sights tints to D-033 color — aiming at a friend (green tint) should feel wrong.
- All transitions 150ms linear. z-layer 7. Never changes by zone or narrative state ([D-045](#d-045-art-direction--environmental-neutrality-strict-zero-shift)).
- Cursor changes on LOS, not just proximity. Click interaction range: ~2 sim tiles (= 1m per [D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)). Weapon-selected mode suppresses interaction prompts unless Shift held ("combat intent trumps social intent").
- **Diegetic test (Stig):** Interaction labels render on z-layer 6 (insert overlay). If the insert is off, labels disappear. Passes the "is this information from the character's implant?" test.
- **Rationale:** Diegetic — cursor is the insert's own interface element. Consistent with [D-048](#d-048-neural-insert-overlay--visual-design). Entity in weapon sights tinting to D-033 creates moral friction.
- **Cross-reference:** Insert overlay ([D-048](#d-048-neural-insert-overlay--visual-design)), entity color ([D-033](#d-033-entity-color--relationship-to-player)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Araminta (visual spec), Stig (UX rules + diegetic test), Ozzie (weapon suppression)
- **Dissent:** None.
### D-057: Entity interaction — vertical list, insert-styled
- **Date:** 2026-02-13
- **Decision:** Entity interactions use a compact vertical list (not radial). 2-4 options max, anchored to entity position. Insert-styled with Araminta's geometric aesthetic. New options unlocked by knowledge changes are highlighted with a gradient glow background. Radial menu reserved for world menu only ([D-058](#d-058-world-menu--radial-4-spokes)). Max 3 visible response options in dialogue context.
- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag").
- **Diegetic test:** Labels render on z-layer 6. If insert is off, labels disappear.
- **Rationale:** Variable-length text options (e.g., confrontation lines in character voice) break radial spatial memory. List handles 1-4 options cleanly. New-item glow signals "something changed" without UX hazard of geometry transforming under cursor. Two-phase computation enables character differentiation without separate verb systems.
- **References:** Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list).
- **Cross-reference:** Cursor states ([D-056](#d-056-cursor-states--insert-styled-geometric)), knowledge graph ([D-041](architecture.md#d-041-knowledge-graph-data-model)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (vertical list structure + diegetic test), Araminta (insert aesthetic), Dudley (two-phase verb computation), Nigel (character-archetype verb sets). Lead resolved: Stig's structure, Araminta's styling.
- **Dissent:** Araminta argued for spoke radial (geometry transformation signals qualitative knowledge change — new spoke growing). Lead rejected: items moving under cursor when knowledge changes is a moving goalpost (bad UX while aiming at an option).
### D-058: World menu — radial, 4 spokes
- **Date:** 2026-02-13
- **Decision:** Right-click opens a radial world menu. Four spokes: Observe (eye icon), Insert (phone icon), Comms (signal icon), Wait (clock icon). Insert-styled: geometric lines, thin spokes with icons, nearly transparent. Renders on z-layer 6. Drag-release for power users (high-speed drag-to-select), click-click for newcomers. v0.1: 2 spokes only (Observe + Insert), scale to 5-6 later.
- **Rationale:** Radial works for world menu because items are fixed categories that don't vary by knowledge state (unlike entity verbs). Spatial memory builds quickly (~10 minutes). Drag-to-select makes the radial feel fast and fluid for experienced players.
- **Cross-reference:** Entity interaction ([D-057](#d-057-entity-interaction--vertical-list-insert-styled)), insert overlay ([D-048](#d-048-neural-insert-overlay--visual-design)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (structure + implementation), Araminta (insert aesthetic)
- **Dissent:** None.
### D-059: Fog — shader-based, five layers, knowledge-graph-driven
- **Date:** 2026-02-13
- **Decision:** Fog is a shader-driven system (not particles) with five distinct layers:
1. **Clear (vision cone):** Soft gradient edge over 6-8 sim tiles (= 3-4 visual tiles per [D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)), no hard line. Darkwood approach.
2. **Light fog (peripheral):** Desaturated 40-50%, brightness -30%. Animated Perlin noise overlay (8-10s cycle). Entity [D-033](#d-033-entity-color--relationship-to-player) colors visible but reduced.
3. **Deep fog (previously explored):** Near-monochrome with ~10% "zone temperature" tint (bar=warm dark, hub=cool dark, corridor=neutral dark). More pronounced noise (15-20s cycle). Fog breathes.
4. **Unexplored + maps app:** Geometric wireframe outlines #333340. Insert data aesthetic.
5. **Unexplored, no maps:** Solid near-black #12141a. Information zero.
- **Fog entities:**
- Sound pings: 2-3 thin concentric expanding rings (sonar-style) from source direction, insert white-blue. Loud = 3 rings bright fast. Quiet = 1 ring faint slow. Fade over 1.5s.
- Recognized entity: [D-033](#d-033-entity-color--relationship-to-player) color glow + faint identifying silhouette feature (e.g., "Kael's vest") + 0.8s breathing pulse + ±0.5 tile position drift (approximate, not exact).
- Unrecognized entity: neutral grey #555566 blob. No silhouette. No identifying features.
- Recognition transition governed by cognitive delay ([D-060](#d-060-cognitive-delay-for-fog-recognition)).
- **Knowledge-graph-driven:** Same fog shows different information per character based on their KG. Smuggler recognizes dock workers = green icons. Detective sees same entities = grey blobs. "Fog is not darkness — it's the absence of your attention."
- **Soft perception degradation:** At high overlay load, diegetic scan-line interference (insert under strain). No hard cap on simultaneous perception modes — soft visual warning instead.
- **Performance:** <1ms/frame total. Vision cone = PointLight2D. Fog = natural darkness + noise shader on CanvasGroup (Layer 5). Sound pings = 0-5 sprites. Entity ghosts = 0-10 sprites typical.
- **Cross-reference:** Fog of perception ([D-011](#d-011-fog-of-perception-is-non-negotiable-pillar-1-infrastructure)), sound model ([D-018](#d-018-three-range-sound-model)), perception modes ([D-017](#d-017-perception-modes-as-character-build-system)), entity color ([D-033](#d-033-entity-color--relationship-to-player)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers)), knowledge graph ([D-041](architecture.md#d-041-knowledge-graph-data-model))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Araminta (full visual spec + "fog is not darkness"), Tyre (performance validation), Nigel (replayability case — fog as primary divergence mechanism), Gestalt (signal/answer framework + soft cap rejection), Ozzie (false-positive shapes proposal)
- **Dissent:** Gestalt proposed hard cap of 2 active perception modes. Lead rejected — replaced with Araminta's diegetic soft degradation.
### D-060: Cognitive delay for fog recognition
- **Date:** 2026-02-13
- **Decision:** When the player recognizes a heard/sensed entity in fog, recognition is NOT instant. Single cognitive delay system: 0.6s base, 0.3s when observe_anomaly triggers (urgent context). Values are tunable via playtesting. Monologue fires DURING the delay ("Those footsteps... that's Kael's walk"), not after — the monologue IS the recognition. Visual transition: grey blob → [D-033](#d-033-entity-color--relationship-to-player) color + silhouette feature over ~0.3s within the cognitive delay window. Natural recognition (organic resolve) feels different from sensor recognition (digital snap with biometric ID — different monologue voice too).
- **Rationale:** Recognition should feel like a cognitive event, not a UI update. The delay creates a moment where the player's brain and the character's brain are working together. Context-sensitive urgency (0.3s for anomalies) prevents the delay from feeling sluggish in tense situations.
- **Cross-reference:** Fog layers ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven)), monologue ([D-016](#d-016-internal-monologue-as-core-perceptionatmosphere-system)), sound model ([D-018](#d-018-three-range-sound-model))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Ozzie (timing values + urgency split, adopted), Araminta (visual transition spec), Gestalt (longer values not adopted, but playtesting may adjust)
- **Dissent:** Gestalt proposed 0.8-1.2s (longer, more contemplative). Lead chose Ozzie's shorter values as starting point.
### D-061: Dialogue box — bottom screen, max 20% height, no portraits
- **Date:** 2026-02-13
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width (not percentage-based — exact value TBD). Layout: NPC speech top, player response options below, left-aligned. Max 3 response options visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness.
- **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen.
- **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Stig (UI spec + no portraits), Lead (20% height constraint + max-width directive)
- **Dissent:** Stig initially proposed 25% height and 50% width centered. Lead constrained to 20% height and max-width.
---
*8 decisions. Last updated: 2026-02-11*
*23 decisions. Last updated: 2026-02-14*
+42 -1
View File
@@ -132,6 +132,47 @@ What we're building: game concept, design pillars, prototype definition, map spe
- **Raised by:** Ozzie (Round 1 identification, Round 2 budget), project lead (all 6 promoted, directive #8)
- **Dissent:** None
### D-051: "Settling is placement" — design principle
- **Date:** 2026-02-12
- **Decision:** Object density in a space correlates with how settled it is. The bar is full because Lera made it home. Empty corridors are unsettled. Investigation reads placement as intention — every placed tile is someone's decision. The tile-based world (D-043, 64x64px visual tiles on dual-scale grid per [D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)) is thematically load-bearing: stations are literally built from prefab modular construction, so the tile grid IS the construction grid.
- **Corollary — "Investigation is archaeology of intention" (Gore):** Reading the tile world is reading decisions. A cargo crate in the wrong place. A personal item in a maintenance corridor. The mundane environment IS the evidence.
- **Cross-reference:** Favorite colors ([D-052](perception.md#d-052-character-favorite-colors--object-layer-identification))
- **Canonical reference:** `docs/workshops/art-direction-mood-board/workshop-outcomes.md` §1.3, §7
- **Raised by:** Gore (both principles), unanimously endorsed.
- **Dissent:** None
### D-053: Movement as stance toggle system
- **Date:** 2026-02-13
- **Decision:** Movement uses a stance toggle ladder: Sprint / Walk / Careful / Crouch / (Prone future). Toggle-based, not hold-to-activate. Each archetype has a default stance via MovementProfile component (e.g., smuggler defaults to Walk, detective defaults to Walk). All characters have access to all stances. Prone: toggle out only in normal play (must explicitly stand up); future combat allows "hit the deck" quick-entry.
- **Stance values:**
- Sprint: 1 tile/1 tick. Monologue at 40% rate (urgent only). Loud footsteps. Interaction buffer cleared ([D-055](architecture.md#d-055-sprint-explicitly-suppresses-interaction-buffer)).
- Walk: 1 tile/2 ticks. Monologue at 100% rate. Normal footsteps. Default stance.
- Careful: 1 tile/3 ticks. Monologue at 150% rate + "tell notice bonus." Quiet footsteps. Enhanced eavesdropping via ListeningFocus (stationary_ticks accumulation).
- Crouch: TBD speed. Very quiet footsteps. Uses Prone/Seated occupancy layer ([D-054](architecture.md#d-054-tile-based-movement-with-same-tile-occupancy)).
- Prone: Future. Minimal noise. Toggle-out only.
- **Perception coupling:** Sprint suppresses interpretation (monologue), not data (overlays still render). Careful enhances observation. Composable via multiplicative formula: `movement_modifier * perception_load_modifier`. All stances: NO vision cone change — perception change is cognitive, not sensory.
- **Emergent tactics:** Sprint ahead to get position → Careful to wait and observe → Walk when target passes. Speed modes compose into surveillance patterns.
- **v0.1:** Sprint, Walk, Careful, Crouch. Prone deferred.
- **Cross-reference:** Tile-based movement ([D-054](architecture.md#d-054-tile-based-movement-with-same-tile-occupancy)), sprint suppression ([D-055](architecture.md#d-055-sprint-explicitly-suppresses-interaction-buffer)), sound model ([D-018](perception.md#d-018-three-range-sound-model))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Lead (stance toggle, final call), Gestalt (Walk/Sprint/Careful triad + perception coupling), Dudley (MovementProfile + tick values), Ozzie (perception gradient), Nigel (character-defining speed)
- **Dissent:** None after lead call.
### D-065: Smuggler inventory — knowledge-primary with physical evidence
- **Date:** 2026-02-13
- **Decision:** Knowledge is the primary "inventory" for all characters (you SAW the manifest, not you HAVE it). The smuggler additionally gets a minimal physical inventory for v0.1: 3 specific items (manifest copy, corridor access token, personal comm log). Capacity per archetype: smuggler 3-4 slots, detective 2 slots. Carried items are PRIVATE — they exist behind the information boundary ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline) principle 2) and are not visible to other entities unless revealed via search, scan, or confrontation. Server implementation: world entities with CarriedBy component. Verbs: Take, Place.
- **Evidence presentation differs by archetype:** Detective sees case-file-style entries (structured: what/where/when/source/confidence, insert suggests links). Smuggler sees personal notebook (organized by person, informal voice, no contradiction flags). Same underlying knowledge graph, different presentation layer.
- **v0.1 items (Paula):**
1. Manifest copy — proves cargo discrepancy. Leverage proof (smuggler's word doesn't carry institutional weight).
2. Corridor access token — proves ring membership. Physical proof of social network position.
3. Personal comm log — bridge between KG knowledge and provable leverage. Recorded conversations.
- **UI:** Pocket icons, bottom-right of screen, 40x40px. No empty slots displayed — icons appear only when items are carried.
- **Rationale:** Smuggler's word doesn't carry institutional weight — they need tangible proof for leverage. Detective's word IS evidence (institutional authority), so they're mostly KG-only. Three items demonstrate the risk/reward concept and differentiate the smuggler's gameplay loop. Contraband carried on person can be detected if scanned by security.
- **Cross-reference:** Knowledge graph ([D-041](architecture.md#d-041-knowledge-graph-data-model)), information boundaries ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline)), vertical slice ([D-027](#d-027-vertical-slice--smuggler--detective-two-character-proof)), contraband ([D-037](content.md#d-037-contraband-specification))
- **Source:** Control & Interaction Workshop (2026-02-13)
- **Raised by:** Lead (smuggler needs inventory), Paula (three items + presentation split), Gestalt (knowledge-primary framework), Tyre (minimal implementation: SmallVec<3>), Dudley (server model: BTreeMap + info boundary)
- **Dissent:** Tyre initially argued zero physical items in v0.1 (saves 3-4 sprints). Adapted with minimal implementation after lead directive.
---
*11 decisions (9 active, 2 superseded). Last updated: 2026-02-11*
*14 decisions (12 active, 2 superseded). Last updated: 2026-02-13*
+354
View File
@@ -0,0 +1,354 @@
# Flying Taxi Over Cityscape — Architecture Analysis
Author: Tyre (architecture) | Sprint: 6 | Ref: D-049, z-layer-gap-analysis.md
## The Question
> Theoretically this will allow the player to take a flying taxi over a cityscape right?
Does the three-scope z-layer architecture support a gameplay scenario where the player is in a flying vehicle ascending over a city — seeing the cityscape below with buildings, streets, NPCs getting smaller and fading?
## Short Answer
**Yes — the architecture supports it.** The z-range allocation, CanvasGroup compositing, scale/alpha model, and negative-z lower floor system all generalize cleanly to a flight scenario. No architectural changes are needed. But the RENDERER needs a flight mode that doesn't exist yet — the architecture provides the skeleton, the renderer provides the muscle.
Tier 1 (architecture) — already solved. Tier 2 (renderer) — new systems needed, medium effort.
---
## What the Architecture Already Supports
### 1. The Floor Generalization
*cracks knuckles* — this is where it gets elegant.
The negative-z floor system was designed for "looking down through a hole" (balcony, catwalk, atrium). But there's nothing hole-specific about it. The system works like this:
- Player has a "current floor" (derived from z_step)
- Floors below render at negative z: z:-100 per floor
- Each lower floor gets scale < 1.0 (depth illusion) and modulate dimming
- Parallax from scale-around-player during camera movement sells the depth
In a flying taxi, the player's "current floor" IS the taxi's altitude. As the taxi ascends from floor 0 to floor 5:
```
Altitude 0 (ground): current floor = 0, nothing below
Altitude 1: current floor = 1, floor 0 at z:-100 (scale 0.97)
Altitude 3: current floor = 3, floors 0-2 at z:-100 to z:-300
Altitude 5: current floor = 5, floors 0-4 at z:-100 to z:-500
Altitude 10: current floor = 10, floors 0-9 at z:-100 to z:-1000
```
The architecture doesn't care WHY the player is above other floors — it just renders lower floors at the appropriate depth. Flying taxi, balcony, jetpack, telekinetic levitation — same system.
### 2. Scale + Alpha for Depth
The existing depth treatment composes:
| Component | Per-floor effect | Source |
|-----------|-----------------|--------|
| Scale reduction | × 0.97 per floor distance | gap-analysis: Distance Scaling |
| Modulate dimming | progressive desaturation + alpha | gap-analysis: Fog Interaction |
| Parallax | content moves slower than camera | gap-analysis: Parallax Effect |
| Occlusion | higher z draws over lower z | CanvasGroup z-ordering |
At altitude 5, the ground floor renders at:
- Scale: 0.97^5 = 0.859 (86% size)
- Modulate: heavily dimmed and desaturated
- Parallax: content moves at 86% of camera speed
At altitude 10 (render ceiling):
- Scale: 0.97^10 = 0.737 (74% size)
- Modulate: barely visible, ghostly
- Parallax: content moves at 74% of camera speed — clear depth separation
This is exactly the visual of "city getting smaller below you."
### 3. CanvasGroup Compositing
CanvasGroup collects ALL children, sorts by z_index (negative first), composites into a single texture. Then FogOverlay draws over it. Whether there are 2 lower floors or 10, the compositing pipeline is identical. No architectural limit.
Godot z_index range: -4096 to 4096. At z:-100 per floor, we support 40 floors of depth before hitting the limit. The render ceiling (10 floors) is well within range.
### 4. The Taxi Itself
The player's taxi is a vehicle entity. As it ascends, its z_step increases. From the rendering perspective:
- The taxi is on the "current floor" (always at the camera center)
- The taxi does NOT get the ascending-object scale+alpha treatment (it's not flying AWAY from the player — the player IS in it)
- Other flying objects at the same altitude render normally relative to the taxi
- Objects at lower altitude render via the lower floor system
The architecture already handles this distinction: "current floor" content renders at z:0-400, lower floors render at negative z.
### 5. Render Ceiling as Flight Ceiling
The render ceiling (10 floors / 25m) serves double duty:
- For ground-based gameplay: maximum altitude for visible sprites (drones, ships)
- For flight gameplay: maximum flight altitude with full rendering
Above the render ceiling, the game transitions to a different mode (narrative cutscene, map view, fast travel). This is a gameplay design constraint, not an architecture limitation.
---
## What the Architecture Does NOT Provide (Gaps)
These are renderer-level systems that need building. The architecture accommodates all of them — the z-ranges, compositing model, and scene tree structure are correct. But the scripts don't exist yet.
### Gap 1: Dynamic Visible Floor Window
**Current:** `VISIBLE_FLOOR_DEPTH = 2` (constant, ±2 floors)
**Needed:** Dynamic window that expands with altitude
```gdscript
# The visible floor window grows with altitude, capped at render ceiling
func get_visible_depth(altitude_floors: int) -> int:
return mini(altitude_floors, Constants.RENDER_CEILING_FLOORS)
```
At altitude 2, you see 2 floors below (same as current). At altitude 8, you see 8 floors below. At altitude 10+, capped at 10.
**Effort: trivial.** Change one constant to one function call. The lower floor node creation is already designed to be dynamic (gap-analysis: "created dynamically when the player enters a location with vertical visibility").
### Gap 2: LOD Tiers for Distant Floors
**Current:** All visible floors render at full detail (tiles + entities + objects)
**Needed:** Progressive LOD — distant floors simplify
Rendering 10 floors at full detail would cost ~1.6ms (10 × 0.16ms per floor budget). That's manageable but wasteful — floors 7-10 below are barely visible through heavy dimming. Three LOD tiers:
| Distance (floors) | LOD tier | Content | Cost estimate |
|-------------------|----------|---------|---------------|
| 0-2 | Full | Tiles + objects + entities + VFX | ~0.16ms/floor |
| 3-5 | Reduced | Tiles + entity dots (no sprites) | ~0.08ms/floor |
| 6-10 | Minimal | Colored rectangles per building/zone | ~0.02ms/floor |
Total at altitude 10: 2×0.16 + 3×0.08 + 5×0.02 = 0.66ms. Well within budget.
**Effort: medium.** Needs a LOD manager that switches floor group content based on distance. The z-range allocation doesn't change — only what gets placed INTO each floor's node group.
### Gap 3: Camera Zoom Transition
**Current:** Camera zoom fixed at Vector2(2, 2)
**Needed:** Camera zooms out with altitude to show more of the city
```gdscript
# Zoom decreases (shows more) as altitude increases
var altitude_factor := float(altitude_floors) / Constants.RENDER_CEILING_FLOORS
var zoom_level := lerpf(2.0, 0.8, altitude_factor) # 2.0 at ground, 0.8 at ceiling
camera.zoom = Vector2(zoom_level, zoom_level)
```
At ground level: zoom 2.0 (current, focused). At altitude 10: zoom 0.8 (wide, showing city below). The zoom transition should be smooth (tween over the ascent duration).
**Effort: trivial.** One zoom tween on Camera2D. No architecture impact.
### Gap 4: Empty Current Floor
**Current:** The "current floor" always renders ground tiles at z:0
**Needed:** When airborne, the current floor is empty (you're in the sky)
The renderer needs to detect "player is airborne" and:
- Skip rendering FloorTiles at z:0 (no ground beneath your feet)
- Skip YSortGroup ground content (no furniture/walls at sky altitude)
- Keep the Airborne (z:200) and HighAirborne (z:350) nodes active for other flying objects
- The taxi entity renders as a special case (the vehicle you're in)
**Effort: low.** Conditional in world_renderer.update_from_state() — if player is airborne, don't populate current-floor ground content.
### Gap 5: Cityscape Composition
A city viewed from above shows a different visual than individual floors viewed through holes:
- **Rooftops** of buildings at various heights (not interior floors)
- **Streets** visible between buildings (ground floor, open air)
- **Parks/plazas** as ground-level open areas
- **Building heights vary** — a 5-story building next to a 2-story building means different content at the same XY at different altitudes
The server needs to send appropriate "viewed from above" content per tile per floor. The ARCHITECTURE handles this (per-floor tile data, per-floor entity data), but the server's snapshot generation needs a flight mode that sends rooftop tiles instead of interior tiles for floors below the player's altitude.
**Effort: server-side, medium.** The client architecture is ready — it just renders whatever tiles the server provides at each floor level. The server needs to know the player is airborne and send rooftop/exterior content instead of interior content for visible floors.
### Gap 6: Flight Fog Treatment
**Current:** Fog shader applies current-floor visibility. Option 2 (modulate dimming) for lower floors.
**Needed:** At altitude, fog treatment must adapt
Two approaches that work within the architecture:
**A. Modulate-only (v0.1, simple):** Keep using per-floor modulate for depth. Disable the current-floor fog shader (nothing to fog at sky altitude) or switch it to a "cloud/haze" mode. Lower floors are already dimmed by modulate — fog adds atmospheric depth.
**B. Per-floor fog (v0.2):** Each lower floor gets its own visibility texture from the server. The fog shader samples the correct texture per floor. This was already designed as Option 1 in the gap analysis — it generalizes to flight.
**Effort: low for A, medium for B.** The architecture supports both. The choice is visual quality vs. implementation cost.
---
## What the Full Flight Mode Renderer Looks Like
When the player enters a flying vehicle, the renderer switches to flight mode:
```
Flight Mode Renderer State:
current_floor = taxi.altitude_floor
visible_depth = min(current_floor, RENDER_CEILING_FLOORS)
camera.zoom = lerp(2.0, 0.8, altitude_factor)
FloorTiles z:0 = EMPTY (airborne, no ground)
FloorObjects z:10 = taxi shadow on ground? (only if altitude < ceiling)
YSortGroup z:100 = other flying objects at same altitude
LowerFloor[1..N] z:-100..-N*100 = city floors below
LOD tier based on distance:
1-2 floors: full tiles + entity sprites
3-5 floors: tiles + entity dots
6-10 floors: macro colored blocks
Scale: 0.97^distance
Modulate: progressive dim/desat
Parallax: scale-around-player, slower movement
```
### Scene Tree in Flight Mode
```
FogGroup (CanvasGroup)
LowerFloor10 (Node2D) z:-1000 [LOD: minimal, scale 0.74]
LowerFloor9 (Node2D) z:-900 [LOD: minimal, scale 0.76]
...
LowerFloor6 (Node2D) z:-600 [LOD: minimal, scale 0.83]
LowerFloor5 (Node2D) z:-500 [LOD: reduced, scale 0.86]
LowerFloor4 (Node2D) z:-400 [LOD: reduced, scale 0.88]
LowerFloor3 (Node2D) z:-300 [LOD: reduced, scale 0.91]
LowerFloor2 (Node2D) z:-200 [LOD: full, scale 0.94]
LowerFloor1 (Node2D) z:-100 [LOD: full, scale 0.97]
FloorTiles z:0 [empty — sky]
FloorObjects z:10 [taxi ground shadow]
YSortGroup z:100 [other flying objects at altitude]
Airborne z:200 [higher flying objects]
Overhead z:300 [empty — no ceiling in sky]
HighAirborne z:350 [objects above taxi altitude]
FogOverlay z:900 [altitude haze / disabled]
```
This is a direct extension of the existing gap-analysis architecture. No structural changes — just more LowerFloor nodes and conditional content.
---
## The Cityscape Visual
What does this actually look like at altitude 5 (roughly 12.5m, ~4th story height)?
```
┌─────────────────────────────────────┐
│ │ Camera view (zoomed to ~1.2)
│ ┌────┐ ┌──────────┐ │
│ │roof│ │ rooftop │ │ Building rooftops (floor 5 tiles)
│ │ 5F │ │ 3F │ │ Different heights visible
│ └────┘ └──────────┘ │
│ │
│ ═══════════════════════════ │ Street (floor 0, dimmed, 86% scale)
│ · · · · · · │ NPC dots on the street
│ ┌──────┐ │
│ │ roof │ │ Another building (floor 2, dimmer)
│ │ 2F │ │
│ └──────┘ │
│ │
│ ▓▓▓▓▓▓▓ │ Park (floor 0, green tint, 86% scale)
│ │
│ [taxi shadow] │ Your taxi's ground shadow
│ │
└─────────────────────────────────────┘
```
Buildings taller than the taxi altitude show their rooftops at higher z (closer to current floor). Short buildings show rooftops that are further away (lower z, more dimmed, smaller scale). Streets and ground are the most distant layer — heavily dimmed, small scale, only entity dots visible.
The parallax sells it: when the taxi moves, ground-level content moves noticeably slower than rooftop-level content. You perceive DEPTH even in a 2D top-down view.
---
## Performance Budget
| Component | At altitude 5 | At altitude 10 (ceiling) |
|-----------|--------------|-------------------------|
| LOD Full floors (0-2) | 2 × 0.16ms = 0.32ms | 2 × 0.16ms = 0.32ms |
| LOD Reduced floors (3-5) | 3 × 0.08ms = 0.24ms | 3 × 0.08ms = 0.24ms |
| LOD Minimal floors (6-10) | — | 5 × 0.02ms = 0.10ms |
| Camera zoom | ~0ms | ~0ms |
| Scale transforms | ~0ms (GPU) | ~0ms (GPU) |
| Compositing | ~0.1ms | ~0.15ms |
| **Total flight overhead** | **~0.66ms** | **~0.81ms** |
Compared to ground-level rendering (~2ms total frame budget used): flight adds 0.66-0.81ms. Total stays under 3ms. Well within 16ms frame budget at 60fps.
The LOD system is the key performance enabler. Without it, 10 full floors would cost ~1.6ms — still feasible but wasteful.
---
## What the Server Needs to Provide
The architecture is client-ready. The server needs:
1. **Airborne player state:** The ObserverSnapshot needs to indicate the player is airborne (not on a surface). The client uses this to switch to flight rendering mode.
2. **Multi-floor visible tiles:** When airborne, the server sends tile data for ALL visible floors below (up to render ceiling), tagged with floor_index. Currently the server sends tiles for the current floor only.
3. **Rooftop vs. interior tiles:** For floors below the player, send exterior/rooftop tile data, not interior layouts. A building's 3rd floor seen from above shows the roof, not the rooms inside. This is a server-side content selection issue.
4. **Per-floor entity data:** Entities on visible floors below, grouped by floor_index. The existing entity data already includes z_step — the client can derive floor_index from it. But the server's visibility calculation needs to include ground-level entities visible from above (not just same-floor LOS).
5. **Building height data:** For LOD Minimal tier, the server could send simplified building footprints (position + height + color) instead of per-tile data. This is an optimization for high-altitude rendering, not a requirement.
None of these require protocol version changes — they're extensions to existing ObserverSnapshot fields. The `visible_tiles` and `visible_entities` arrays already support per-tile and per-entity floor data via z coordinates.
---
## Implementation Roadmap
**Phase 0 — Already done (Sprint 6):**
- Z-range allocation supports negative z to -4096
- RENDER_CEILING_FLOORS = 10 defined
- VISIBLE_FLOOR_DEPTH = 2 defined (becomes dynamic)
- CanvasGroup compositing verified for multi-floor
- Scale + modulate depth treatment designed
**Phase 1 — Basic flight (script work only, no server changes):**
- Flight mode flag in world_renderer
- Dynamic visible floor window
- Camera zoom tween based on altitude
- Empty current floor when airborne
- Use existing ±2 floor rendering (limited but functional)
- Can demo with hardcoded lower floor content
**Phase 2 — Full cityscape (requires server support):**
- Server sends multi-floor tile data for airborne players
- LOD tier system in floor renderer
- Dynamic LowerFloor node creation/destruction
- Rooftop tile selection on server side
**Phase 3 — Polish:**
- Altitude fog/haze shader mode
- Entity dot rendering for distant floors
- Macro rendering for LOD Minimal tier
- Taxi interior UI overlay (passenger view)
- Ground shadow for taxi at z:10
---
## Verdict
The three-scope z-layer architecture **fully supports** the flying taxi scenario. Every component — negative z for lower floors, scale/alpha depth treatment, CanvasGroup compositing, render ceiling, dynamic floor groups — generalizes from "looking through a hole" to "flying above a city."
What exists: the skeleton (z-ranges, compositing, contracts).
What needs building: the muscle (flight renderer, LOD, server multi-floor data).
The architecture made the right bets:
- **Negative z was designed generously** (-4096 available, -1000 needed for 10 floors)
- **Scale-around-player parallax** works identically for holes and flight
- **CanvasGroup composites any number of floors** without structural changes
- **Render ceiling defines the flight ceiling** — one constant, dual purpose
- **Per-floor modulate** provides atmospheric depth without shader changes
Feasibility: **Challenging but doable.** The architecture is the easy part (done). The renderer flight mode is a medium-effort feature (Phase 1: ~1 sprint). Full cityscape with server support is larger (Phase 2: ~2 sprints). But there are no architecture blockers — no rethinking z-ranges, no CanvasGroup limitations, no compositing rewrites.
*The flying taxi just fell out of the architecture for free. That's what good range allocation gets you.*
+287
View File
@@ -0,0 +1,287 @@
# Fog Shader Architecture — #430 (D-059)
Ticket: #430 | Decision: D-059 | Sprint: 6
Author: Tyre (architecture) | Implementer: Stig
## Overview
Complete rewrite of the fog system. Delete `fog_renderer.gd` (TileMapLayer-based, 2-state binary fog) and replace with a shader-driven, 5-layer fog system on a CanvasGroup.
The fog is **knowledge-graph-driven** — the same fog shows different information per character based on their KG. "Fog is not darkness — it's the absence of your attention."
## Scene Tree (D-049 Compliant)
After the z-layer restructure, the relevant scene tree is:
```
World (Node2D) [world_renderer.gd]
FogGroup (CanvasGroup) # Composites layers 0-4
FloorTiles (TileMapLayer) # z:0
FloorObjects (Node2D) # z:1
YSortGroup (Node2D, y_sort) # z:2-3
Entities (Node2D, y_sort) # z:3
Overhead (Node2D) # z:4
FogOverlay (Node2D) [fog_shader.gd] # z:10, replaces old TileMapLayer
FogEntities (Node2D) # Sound pings, entity ghosts (sprites)
```
**Key:** `FogGroup` (CanvasGroup) composites everything in layers 0-4 into a single texture. The fog shader does NOT go on FogGroup — instead, the `FogOverlay` node draws a full-screen fog quad whose fragment shader reads visibility data to determine what's clear, fogged, or hidden.
## Architecture
### Why NOT a CanvasGroup material shader?
The initial instinct is to put a fragment shader on the CanvasGroup itself (as `material`). However, this has a problem: the CanvasGroup shader can only darken/modify what's already rendered. For unexplored areas, we need to draw **over** the world content (solid near-black, or wireframe overlay). A CanvasGroup shader can desaturate and dim, but it can't add new visual content (wireframe outlines for layer 4).
**Solution:** `FogOverlay` is a `Node2D` with a `ColorRect` child (full-viewport size) that uses a `ShaderMaterial`. The shader reads uniform textures to determine per-pixel fog state. This overlay sits above the FogGroup in z-order and composites fog effects over the world.
### Data Flow
```
Server (each tick)
└─ ObserverSnapshot
├─ visible_positions: Dictionary<Vector2i, true> (LOS result)
├─ visibility_sectors: Dictionary<Vector2i, "Forward"|"Peripheral">
└─ visible_tiles: Array<{x, y, z, type}> (known map extent)
GameState (autoload)
└─ Stores all above
FogState (new autoload)
├─ visibility_texture: ImageTexture # Updated every tick from visible_positions
├─ exploration_texture: ImageTexture # Persistent — accumulates explored areas
├─ zone_tint_texture: ImageTexture # Per-tile zone temperature tint
└─ map_bounds: Rect2i # Known map extent for texture sizing
FogOverlay (Node2D) [fog_shader.gd]
├─ ColorRect with ShaderMaterial
│ └─ Fragment shader reads:
│ uniform sampler2D visibility_tex; # Current LOS
│ uniform sampler2D exploration_tex; # Historical explored
│ uniform sampler2D zone_tint_tex; # Zone temperature colors
│ uniform float time; # For noise animation
│ uniform vec2 player_pos; # Vision cone center
│ uniform vec2 map_offset; # World-to-texture mapping
│ uniform vec2 map_size; # Texture dimensions in tiles
└─ FogEntities (Node2D)
└─ Sound pings, recognized entities, grey blobs (sprites)
```
### FogState Autoload
New autoload: `client/scripts/autoloads/fog_state.gd`
This manages fog-related game state that persists across frames. It is NOT a renderer — it's data.
```gdscript
class_name FogState
extends Node
# Texture dimensions match map bounds (1 pixel per sim tile)
var map_bounds: Rect2i = Rect2i()
var _visibility_image: Image # Red channel: 0=not visible, 255=visible
var _exploration_image: Image # Red channel: 0=unexplored, 128=explored (deep fog),
# 255=currently visible (clear)
var _zone_tint_image: Image # RGB: zone temperature tint color per tile
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
var zone_tint_texture: ImageTexture
func update_from_state() -> void:
# Called every tick by fog_shader.gd
# 1. Resize textures if map_bounds changed
# 2. Clear visibility_image to 0 (black)
# 3. Write visible_positions from GameState → red channel = 255
# 4. Write visibility_sectors: Forward = 255, Peripheral = 180
# 5. Update exploration_image: any currently-visible pixel → 255,
# previously-visible pixels decay toward 128 over time
# 6. Upload images to textures
```
**Performance note:** `Image.set_pixel()` in a loop is ~0.05ms for 400 tiles. Acceptable. For larger maps, switch to `Image.set_data()` with a pre-built `PackedByteArray`.
### Fragment Shader
File: `client/shaders/fog.gdshader`
The shader determines fog layer per pixel based on the visibility and exploration textures.
```glsl
shader_type canvas_item;
uniform sampler2D visibility_tex : filter_nearest;
uniform sampler2D exploration_tex : filter_nearest;
uniform sampler2D zone_tint_tex : filter_nearest;
uniform vec2 map_offset; // World position of texture origin (in pixels)
uniform vec2 map_size; // Texture size in tiles
uniform float tile_size; // Pixels per tile
uniform float time; // Engine TIME for noise animation
// Fog layer colors
const vec4 FOG_UNEXPLORED = vec4(0.071, 0.078, 0.102, 1.0); // #12141a
const vec4 FOG_WIREFRAME = vec4(0.2, 0.2, 0.251, 1.0); // #333340
const float LIGHT_FOG_DESAT = 0.45; // 40-50% desaturation
const float LIGHT_FOG_DIM = 0.7; // brightness -30%
const float DEEP_FOG_DESAT = 0.9; // near-monochrome
const float DEEP_FOG_DIM = 0.25; // heavy dimming
const float ZONE_TINT_STRENGTH = 0.1; // ~10% zone temperature tint
// Perlin noise (simplified — use Godot's NoiseTexture2D for production quality)
// Alternatively: pass a pre-generated noise texture as another uniform.
void fragment() {
// Map screen pixel to tile coordinate
vec2 world_pos = (SCREEN_UV * vec2(textureSize(visibility_tex, 0))) ;
vec2 tile_uv = world_pos / map_size;
// Sample textures
float vis = texture(visibility_tex, tile_uv).r; // 0-1: current visibility
float explored = texture(exploration_tex, tile_uv).r; // 0-1: exploration state
vec3 zone_tint = texture(zone_tint_tex, tile_uv).rgb;
// Determine fog layer:
// vis > 0.7 → Layer 1: Clear (vision cone) — soft gradient edge
// vis > 0.3 → Layer 2: Light fog (peripheral) — desaturated, noise
// explored > 0.4 → Layer 3: Deep fog (previously explored) — monochrome + tint
// explored > 0.1 → Layer 4: Unexplored + maps — wireframe outlines
// else → Layer 5: Unexplored, no maps — solid near-black
if (vis > 0.7) {
// Layer 1: Clear — soft gradient at edge
float edge = smoothstep(0.7, 1.0, vis);
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge); // Transparent in clear zone
} else if (vis > 0.3) {
// Layer 2: Light fog — desaturated + dim + animated noise
float noise = _perlin(world_pos * 0.02 + vec2(time * 0.1, time * 0.05));
float alpha = mix(0.4, 0.6, noise); // Animated fog density
COLOR = vec4(0.02, 0.02, 0.05, alpha);
} else if (explored > 0.4) {
// Layer 3: Deep fog — near-monochrome + zone tint + breathing noise
float noise = _perlin(world_pos * 0.01 + vec2(time * 0.03, time * 0.02));
vec3 tint = mix(vec3(0.05), zone_tint, ZONE_TINT_STRENGTH);
float alpha = mix(0.75, 0.85, noise); // Fog breathes
COLOR = vec4(tint, alpha);
} else if (explored > 0.1) {
// Layer 4: Unexplored + maps app — geometric wireframe
COLOR = FOG_WIREFRAME;
// TODO: wireframe grid line overlay (1px every tile_size pixels)
} else {
// Layer 5: Unexplored, no maps — information zero
COLOR = FOG_UNEXPLORED;
}
}
```
**Note:** This is the architectural skeleton. The actual shader will need:
- A proper noise function or noise texture uniform (Godot's `NoiseTexture2D` resource)
- Correct world-to-UV coordinate mapping using `SCREEN_UV`, `CANVAS_MATRIX`, or vertex-passed world coords
- The gradient edge for Layer 1 should span 6-8 sim tiles (D-059/D-066)
- Layer 4 wireframe can use `mod()` on world coords for grid lines
### Coordinate Mapping
The shader needs to map screen pixels → tile coordinates to sample the fog textures.
**Approach:** The `ColorRect` is sized to match the viewport. In `_process()`, update its position to track the camera so it covers the visible area. Pass camera offset as a uniform.
Alternatively: use `SCREEN_UV` with `SCREEN_PIXEL_SIZE` and the known camera transform. This avoids moving the ColorRect.
**Recommended:** Make the ColorRect a child of the Camera2D (so it moves with the camera) and pass the camera's world position as a uniform. The shader then computes `world_pos = camera_offset + VERTEX` to get the world coordinate per pixel.
### Vision Cone Integration
The vision cone is already implemented as PointLight2D on the player entity (D-046). For the fog shader, the vision cone data comes through `visibility_tex` (populated from `GameState.visible_positions`). The PointLight2D continues to provide the visual lighting effect on layers 0-4 (inside the FogGroup). The fog shader reads the same LOS data but applies it as fog/no-fog rather than light/dark.
**Important distinction:**
- **PointLight2D** = visual lighting (warm/cool, D-046 color temperature) on world content
- **Fog shader** = information boundary (what the character knows vs doesn't know)
These are separate systems that happen to use the same LOS data. The vision cone PointLight2D should NOT be removed — it provides the Darkwood-style light pooling on the world layer.
### Fog Entities (Sprites on Layer 5)
Fog entities are NOT shader effects — they're GDScript-spawned sprites under `FogOverlay/FogEntities`:
**Sound pings:**
- Scene: `fog_sound_ping.tscn` — 2-3 concentric `Line2D` circles
- Behavior: expand from center, fade over 1.5s, insert white-blue color
- Loud: 3 rings, bright, fast expansion
- Quiet: 1 ring, faint, slow expansion
- Max simultaneous: 5
**Recognized entity (in fog):**
- Scene: `fog_entity_ghost.tscn` — colored glow + silhouette feature
- D-033 color glow (relationship color)
- 0.8s breathing pulse (modulate alpha oscillation)
- +/-0.5 tile position drift (not exact — information is approximate)
- Recognition transition: grey blob → D-033 color over ~0.3s (within D-060 cognitive delay)
**Unrecognized entity (in fog):**
- Scene: `fog_entity_blob.tscn` — neutral grey #555566 blob
- No identifying features, no silhouette
- Position drift same as recognized
**Max simultaneous fog entities:** 10 typical, 15 max. Each is 1-2 draw calls. Trivial.
## FogState Update Lifecycle
```
Per tick (in _process or on snapshot signal):
1. FogState.update_visibility(GameState.visible_positions, GameState.visibility_sectors)
→ Write visibility_image, upload to visibility_texture
2. FogState.update_exploration(GameState.visible_positions)
→ Mark visible tiles as explored, apply decay to non-visible explored tiles
→ Upload to exploration_texture
3. FogOverlay._process():
→ Update shader uniforms (visibility_tex, exploration_tex, time, player_pos)
→ Update fog entity positions/states from ObserverSnapshot fog entity data
```
## Performance Budget
| Component | Budget | Estimate | Notes |
|-----------|--------|----------|-------|
| Visibility texture upload | 0.1ms | ~0.05ms | 400 pixels via set_pixel() |
| Exploration texture update | 0.1ms | ~0.05ms | Incremental — only changed tiles |
| Fragment shader (1080p) | 0.5ms | ~0.2ms | Single full-screen pass, simple math |
| Fog entity sprites | 0.2ms | ~0.05ms | 0-15 sprites, trivial draw calls |
| **Total** | **<1ms** | **~0.35ms** | Well within D-059 budget |
## Files to Create
| File | Type | Purpose |
|------|------|---------|
| `client/scripts/autoloads/fog_state.gd` | Autoload | Fog texture management, exploration persistence |
| `client/scripts/rendering/fog_shader.gd` | Script | FogOverlay node controller, shader uniform updates |
| `client/shaders/fog.gdshader` | Shader | Fragment shader for 5-layer fog |
| `client/scenes/fog_sound_ping.tscn` | Scene | Sound ping rings (deferred to Sprint 7+, #431) |
| `client/scenes/fog_entity_ghost.tscn` | Scene | Recognized entity ghost (deferred to Sprint 7+, #431) |
| `client/scenes/fog_entity_blob.tscn` | Scene | Unrecognized entity blob (deferred to Sprint 7+, #431) |
## Files to Delete
| File | Reason |
|------|--------|
| `client/scripts/rendering/fog_renderer.gd` | Replaced entirely by fog_shader.gd + fog.gdshader |
## Files to Modify
| File | Change |
|------|--------|
| `client/scenes/main.tscn` | Replace FogOverlay TileMapLayer with Node2D + ColorRect |
| `client/scripts/rendering/world_renderer.gd` | Update fog_renderer reference to fog_shader |
| `project.godot` | Add FogState autoload |
## Implementation Notes for Stig
1. **Start with the shader.** Get a basic 2-layer shader working (clear vs opaque) on a ColorRect, then incrementally add layers.
2. **Coordinate mapping is the hardest part.** Getting screen pixels → world tiles → texture UVs correct requires careful math. Test with a known map layout.
3. **Use Godot's NoiseTexture2D** resource for the Perlin noise rather than computing it in the shader. Pass it as a uniform. Scroll the UV offset with TIME for animation.
4. **The gradient edge** (Layer 1, 6-8 sim tiles) is the most visible quality differentiator. Use `smoothstep()` with the distance from the nearest non-visible tile. This may require encoding distance-to-edge in the visibility texture rather than binary 0/255.
5. **Fog entities are Sprint 7+ (#431).** For this sprint, just get the 5-layer fog shader working. The FogEntities node can be empty.
6. **Test with the existing sim_bridge test mode** — it provides a visible_positions Dictionary with a 4-tile radius and Bresenham LOS. Good enough to validate the shader.
## Open Questions
- **Q: How does the "maps app" data reach the client?** Layer 4 (unexplored + maps) needs to know which unexplored tiles the character's insert has map data for. This likely requires a new field in ObserverSnapshot (e.g., `mapped_tiles`). For Sprint 6, treat all explored tiles as "has maps" and all unexplored as "no maps" (layers 3 and 5 only, skip layer 4). Layer 4 is a v0.1.2+ feature.
- **Q: Zone temperature tints — where do they come from?** Currently no per-tile zone data in the snapshot. For Sprint 6, use a hardcoded default (neutral dark). Zone tints require server-side zone metadata.
+758
View File
@@ -0,0 +1,758 @@
# Z-Layer Rendering Pipeline — Gap Analysis
Author: Tyre (architecture) | Sprint: 6 | Decision: D-049 amendment
## Adjustments Incorporated
1. **Fog moves to z:900** — FogOverlay sits OUTSIDE FogGroup (the CanvasGroup), as a sibling under the World node. FogGroup draws at z:0 (default), FogOverlay draws at z:900. The CanvasGroup composites layers 0-300+ into a single texture, then FogOverlay draws the fog shader quad over that. Currently z_index=10 in scene tree — needs update to 900.
2. **Modal at CanvasLayer 30** — New CanvasLayer node in main.tscn at `layer = 30`. Pause menu, inventory modal, death screen. Empty for Sprint 6.
3. **Y-sort occlusion contract** — Formalized below.
4. **Airborne + upper floor rendering** — Proposed below.
---
## Y-Sort Occlusion Contract (Formal)
Most critical architectural constraint in the rendering pipeline. Getting this wrong breaks the entire top-down visual.
**The rule:** In Godot 4, within a y_sort_enabled parent, z_index is the PRIMARY sort key and y-position is SECONDARY. Confirmed via Godot issue #62715 and fix #62837.
**The contract:**
1. **All y-sorted content MUST share z_index = 0** within YSortGroup. Entities, furniture, wall faces — everything that participates in positional occlusion gets z:0.
2. **Nested y_sort_enabled nodes flatten** their children into the parent's sort pool. Entities (y_sort=true, z:0) has its individual entity sprites participate directly in YSortGroup's y-sort alongside furniture.
3. **D-044 entity-wins-ties:** Entities node added AFTER Furniture node in scene tree order. Same y-position means later sibling wins.
4. **Items on surfaces** (cup on table): parent-child node relationship. Child draws after parent. No z_index needed.
5. **Wall faces** (when added): in YSortGroup at z:0. Wall at y=5 occludes entity at y=4 but not y=6. Side/back walls use Overhead (z:300).
**CRITICAL BUG IN CURRENT SCENE TREE:** Entities currently has `z_index = 3`. Entity sprites will ALWAYS draw after furniture regardless of y-position. When furniture sprites are added, they'll be permanently hidden behind entities. **Must change to z_index = 0.**
---
## Airborne, Overhead, and Upper Content
Everything stays inside FogGroup — fog applies uniformly regardless of altitude.
### Fixed Top-Down Camera Constraint (D-019)
The camera is fixed at ~15-20° from vertical. The player never tilts the camera upward. This has major implications for vertical rendering:
- **You never "look up."** The camera is above everything. What you see is always the TOP of things.
- **Upper floors occlude lower floors.** If you're on floor 1 of a 3-story building, the camera sees the roof of floor 3, NOT your floor 1 — unless the game selectively hides/transparentifies upper floors (like Rimworld, Prison Architect).
- **The Overhead layer (z:300) handles everything above the player on the current floor.** Ceiling edges, overhead pipes, upper floor platforms — all rendered as semi-transparent occlusion from directly above.
- **Upper floor entities (z:400+) are only relevant when visible through horizontal offset or floor transparency.** A catwalk extending to the side (you see it because it's offset from your position, not because you looked up) or content on a floor above you where that floor has been made transparent by the game. This is a niche case, not the primary use.
This significantly simplifies the upper range compared to the original proposal.
### Revised Upper Range
- **z:400 UpperContent** — Single range for any upper-floor content visible through horizontal offset or floor transparency. One y-sort group, not per-floor stacking.
- **z:500-899 — Reserved but likely unused.** The fixed camera means you rarely see more than one floor above. Reserved for edge cases (multi-level atrium with transparent floors, specific story moments).
The elaborate per-floor stacking (z:400, z:500, z:600...) from the original proposal is over-engineered for a fixed top-down camera. Simplified to a single UpperContent range.
### Flying Objects: Scale INCREASES with Altitude
With a fixed top-down camera, objects at higher altitude are CLOSER to the camera. This inverts the intuition from the looking-down case:
- **Things below you:** further from camera → scale < 1.0 (shrink)
- **Things at your level:** normal → scale 1.0
- **Things above you (flying):** closer to camera → scale > 1.0 (grow)
A drone at high altitude appears LARGE (close to camera). As it descends to ground level, it shrinks toward normal sprite size. A ship approaching from orbit starts as a large shadow, shrinking as it descends. This is physically correct for a top-down perspective.
**Scale formula for airborne objects:**
```gdscript
# Altitude in floors above current floor
var altitude_floors := (entity.z_step - current_floor_z_step) / 5.0
# 3% size increase per floor of altitude
var scale_factor := 1.0 + altitude_floors * 0.03
airborne_sprite.scale = Vector2(scale_factor, scale_factor)
```
Scale by altitude:
| Altitude | Floors above | Scale | Visual effect |
|----------|-------------|-------|---------------|
| Ground level | 0 | 1.00 | Normal entity size |
| Low airborne | 0.5-1 | 1.02-1.03 | Barely noticeable |
| Mid airborne | 2-3 | 1.06-1.09 | Clearly larger, floating above |
| High airborne | 5 | 1.15 | Prominent, casting shadow below |
| Near ceiling | 8-9 | 1.24-1.27 | Large, approaching render ceiling |
| Render ceiling | 10 | 1.30 | Maximum, beyond this: not rendered |
### Rendering Ceiling
There must be a maximum altitude above which objects stop rendering as sprites. A ship in orbit, a satellite at 500m — these are not visible in the top-down game view.
**Contract: rendering ceiling = 10 floors above current floor = z_step + 50 = 25 meters.**
In the world z-coordinate system:
- z_step is an integer, 0.5m per step
- floor_index = z_step / 5, floor height = 2.5m (5 sub-levels * 0.5m)
- Rendering ceiling: current z_step + 50 (10 floors, 25m)
Above the rendering ceiling:
- Objects do NOT render as sprites
- They MAY cast ground shadows (shadow sprite at z:10, FloorObjects level — a dark ellipse on the ground)
- They MAY produce environmental effects (engine noise, wind particles, lighting changes)
- They MAY appear as insert overlay indicators (radar blip, direction arrow) in the Insert scope
This aligns with both visual limits (at 1.30 scale, a sprite is already uncomfortably large) and gameplay logic (your character's perception doesn't extend 25m straight up in meaningful detail).
**Rendering floor (looking down):** Symmetric — 10 floors below = z_step - 50 = 25m down. Already enforced by the +/- 2 visible floor window for DETAILED rendering. Beyond +/- 2, content could render as simplified dots/shadows rather than full sprites, up to +/- 10. Beyond +/- 10, not rendered at all.
### Revised Cases
**Projectiles / low airborne** (arrow mid-flight, thrown grenade, hovering drone at room height):
- z:200, scale ~1.0-1.03
- Visually above ground entities, below overhead
- NOT y-sorted with ground — always on top of ground content
- Most common airborne case
**High airborne** (drone at altitude, descending shuttle, flying creature):
- z:200, scale 1.03-1.30 (driven by altitude z_step)
- Still inside Airborne node, differentiated by per-sprite scale
- Objects above overhead height (z_step > current + 15, i.e., 3+ floors): render at z:350 (above Overhead at z:300) — they're above the ceiling, so they draw over it
- Casts ground shadow at z:10 (FloorObjects)
**Overhead structure** (ceiling edges, catwalk beams, overhead pipes):
- z:300 unchanged
- Semi-transparent partial occlusion
- NOT scaled — this is structural, not altitude-variable
**Upper floor content** (entities on a catwalk visible through horizontal offset or floor transparency):
- z:400
- Normal scale (they're ON a surface, not flying)
- Own y-sort group for interleaving with each other
- Rare case with fixed top-down camera
### Proposed World Z Layout (Revised)
```
FogGroup (CanvasGroup, z:0)
FloorTiles z:0 ground plane
FloorObjects z:10 cosmetic detail + ground shadows from high-altitude objects
YSortGroup z:100 ground entities/furniture/walls, y-sorted
Airborne z:200 projectiles, low-flying objects (scale ~1.0)
Overhead z:300 ceiling, upper structure, semi-transparent
HighAirborne z:350 flying objects above ceiling height (scale 1.03-1.30)
UpperContent z:400 upper-floor entities via horizontal offset/transparency
[z:500-899 reserved] unlikely to be needed with fixed camera
```
### Design Contract (Revised)
- **z:0-100 Ground:** Floor surfaces + y-sorted content on player's current floor.
- **z:200 Airborne:** Low-altitude flying objects (0-3 floors above). Scale 1.0-1.09. Above ground entities, below overhead.
- **z:300 Overhead:** Structural elements above player. Semi-transparent. No scale.
- **z:350 HighAirborne:** Flying objects above ceiling height (3-10 floors above). Scale 1.09-1.30. Above overhead. Casts ground shadow at z:10.
- **z:400 UpperContent:** Entities on visible upper floors (horizontal offset or transparency). Normal scale. Own y-sort. Rare with fixed camera.
- **z:500-899 reserved:** Unlikely to be needed. Available for edge cases.
- **Rendering ceiling:** 10 floors (25m, z_step + 50). Above this: no sprite rendering. Ground shadows and environmental effects only.
- **Fog uniform across altitude:** Shader reads visibility per tile, doesn't distinguish z_index. Intentional — perception determines what you see, not altitude.
Sprint 6 impact: None. Airborne/HighAirborne/UpperContent don't exist in scene tree yet. Contract reserves the ranges.
---
## Complete Adjusted Pipeline
### World scope (default canvas, z:-200 to z:900)
```
World (Node2D) [world_renderer.gd]
FogGroup (CanvasGroup, z:0)
LowerFloor2 (Node2D) z:-200 [future, dynamic, scale 0.94, modulate dim]
LowerFloor1 (Node2D) z:-100 [future, dynamic, scale 0.97, modulate dim]
FloorTiles (TileMapLayer) z:0
FloorObjects (Node2D) z:10 also: ground shadows from high-altitude objects
YSortGroup (Node2D, y_sort) z:100
Furniture (Node2D) z:0 [future]
Entities (Node2D, y_sort) z:0 FIX from current z:3
WallFaces (Node2D) z:0 [future]
Airborne (Node2D) z:200 [future] low-flying, scale ~1.0
Overhead (Node2D) z:300
HighAirborne (Node2D) z:350 [future] above-ceiling flying, scale 1.03-1.30
UpperContent (Node2D, y_sort) z:400 [future] rare with fixed camera
FogOverlay (Node2D) z:900 OUTSIDE FogGroup, fog shader
```
### Insert scope (CanvasLayer 10)
```
InsertOverlay (CanvasLayer, layer=10)
InteractionPrompt v0.1 fallback
InteractionList D-057 verb list
RadialMenu [future] D-058
PerceptionMarkers [future] entity labels
Minimap [recommend: move from UI, diegetic per D-013]
```
### UI scope (CanvasLayer 20)
```
UILayer (CanvasLayer, layer=20)
HUD health/stamina
Minimap [current, may move to Insert]
MonologueDisplay internal monologue text
CursorRenderer geometric cursor, topmost in UI
```
### Modal scope (CanvasLayer 30)
```
ModalLayer (CanvasLayer, layer=30) NEW, empty for Sprint 6
[future] PauseMenu, InventoryModal, DeathScreen
```
---
## Gap Checks
### CanvasGroup + PointLight2D -- NO GAP
Vision cone PointLight2D (D-046) sits inside FogGroup/YSortGroup/Entities on the player entity. CanvasGroup composites its children WITH lighting into the output texture. Fog overlay then draws over the composited result. Intended behavior: "vision cone lights the world, fog covers what you don't know." These are two separate systems sharing LOS data (as documented in fog-shader-spec.md).
### Camera2D + CanvasLayer -- NO GAP
Camera2D transforms the default canvas (World + FogOverlay). CanvasLayers (Insert, UI, Modal) are NOT affected by camera transform. Insert overlay elements that need world-anchoring (interaction labels near entities) must convert world-to-screen coordinates in their scripts. Standard Godot pattern. interaction_list.gd currently doesn't do this — it's a fixed overlay. Entity-anchored positioning per D-057 is a script change, not architecture.
### Multi-floor transitions -- NO GAP
floor_index = z_step / 5, sub_level = z_step % 5. When the player changes floors: server sends new visible_entities for the new floor, entity renderer removes old and creates new, fog textures update for new floor's visibility. No rendering layer changes needed. Multi-floor visibility (stairwells) would add per-floor y-sort groups at z:400+ — that's v0.2+, no architectural debt now.
### Fog shader coordinate mapping -- NO GAP
FogOverlay at z:900 outside FogGroup. Changing z:10 to z:900 doesn't affect shader behavior — z_index is draw order only, not UV math. fog_shader.gd positions its ColorRect via camera offset, confirmed by reading the source.
### Insert overlay sub-layering -- NO GAP
Within a CanvasLayer, child z_index values are relative to that CanvasLayer's space, not the world space. InsertOverlay children can use z_index 0, 1, 2... for internal ordering without conflicting with world z values. The 1000+ numbering scheme in constants.gd is for documentation/reference. Implementation uses scene tree order or small z_index values.
### z_index range -- NO GAP
Godot 4 z_index valid range: -4096 to 4096. Our highest world z value is 900. Well within range.
### Relative z_index accumulation -- NO GAP
Godot defaults to z_as_relative = true. Within FogGroup: FloorTiles effective z = FogGroup(0) + 0 = 0. FloorObjects effective z = 0 + 10 = 10. YSortGroup effective z = 0 + 100 = 100. Within YSortGroup: Entities z = 0 (relative to YSortGroup). In y-sort mode, this z:0 is used as the sort key within the y-sort pool — not accumulated with parent's z:100. The z:100 on YSortGroup determines when the entire group draws relative to FloorObjects (z:10) and Overhead (z:300). Correct behavior.
### Fog uniformity across altitude -- NO GAP
Fog shader reads visibility_texture per tile position. Doesn't distinguish by z_index. A drone at z:200 and an entity at z:100 on the same tile get the same fog treatment. Intentional — your perception determines what you see, not altitude.
---
## Scene Tree Changes Needed
| Node | Current z_index | Target z_index | Notes |
|------|----------------|---------------|-------|
| FloorTiles | 0 | 0 | Correct, no change |
| FloorObjects | 1 | 10 | Gap for future floor layers |
| YSortGroup | 2 | 100 | Gap for wall base, rubble |
| **Entities** | **3** | **0** | **CRITICAL: fixes y-sort bug** |
| Overhead | 4 | 300 | Gap for upper walls |
| FogOverlay | 10 | 900 | Top of world scope |
| ModalLayer | -- | NEW | CanvasLayer layer=30 |
Constants.gd: full rewrite of the Z_ block to three-scope numbering (World 0-899, Insert CanvasLayer 10, UI CanvasLayer 20, Modal CanvasLayer 30).
---
## Looking Down: Lower Floor Rendering
### The Problem
The pipeline above handles looking UP (overhead z:300, upper floors z:400+) and anchors the current floor at z:0-100. But what renders BELOW z:0? When the player stands on a balcony, catwalk, or upper floor, they may see floors below them.
### Key Insight: Looking Down != Being On That Floor
When you look DOWN at floor N from above, you see a fundamentally different view than when you're ON floor N:
- **On floor N:** Full interior — floor surface, furniture, entities at eye level, walls, overhead
- **Looking down at floor N:** Top-down view — floor surface (or rooftops), furniture tops, entity heads/shoulders. No wall interiors, no overhead (you're above the overhead)
This means lower floor rendering is SIMPLER than same-floor rendering. Each lower floor needs:
- Ground surface (the floor or rooftop you see looking down)
- Floor objects (furniture tops, decoration — simplified)
- Entities (people below, seen from above, y-sorted with each other)
- Optionally airborne on that floor (rare — a drone flying below you)
### Range Allocation: Negative Z
Godot supports z_index -4096 to 4096. Lower floors use negative z ranges, mirroring the upper floor +100 pattern:
```
z:-200 to z:-101 Floor-2 below (max visible depth)
z:-100 to z:-1 Floor-1 below (one floor down)
z:0 Current floor ground (anchor point)
z:10 Current floor cosmetic
z:100 Current floor y-sort
z:200 Current floor airborne
z:300 Current floor overhead
z:400 Upper floor +1
z:500 Upper floor +2 (max visible height)
z:500-899 Reserved
z:900 FogOverlay (outside FogGroup)
```
Within each lower floor's 100-range block:
```
z:X+0 LowerGround — floor surface / rooftop seen from above
z:X+10 LowerObjects — furniture tops, decorations from above
z:X+50 LowerYSort — entities on that floor, y-sorted with each other
z:X+80 LowerAirborne — flying things on that floor (rare)
```
Concrete example — floor one below (z:-100 to z:-1):
```
z:-100 Ground surface (floor tiles or rooftop of floor below)
z:-90 Floor objects (furniture tops from above)
z:-50 Entities (y-sort group — NPCs on the floor below)
z:-20 Airborne (drones flying on that floor, if any)
```
Floor two below (z:-200 to z:-101):
```
z:-200 Ground surface
z:-190 Floor objects
z:-150 Entities (y-sort group)
z:-120 Airborne
```
### Visible Floor Window
**Contract: current floor +/- 2** (5 floors rendered simultaneously max).
Rationale:
- Beyond 2 floors of vertical distance, entities are too small/distant to be meaningful in a top-down view
- 5 simultaneous floors is a generous budget for gameplay scenarios (2-story buildings, catwalks, atrium balconies, station concourses)
- For extreme cases (50-floor skyscraper), clamp to +/- 2 visible. Floors beyond the window render as deep fog color. This is both a performance win and a gameplay statement: your perception doesn't extend that far vertically
Total z range used: -200 to 900. Well within Godot's -4096 to 4096.
### CanvasGroup Interaction: Confirmed Clean
Negative z_index values inside a CanvasGroup work correctly. The CanvasGroup collects ALL children, sorts them by z_index (negative values first), renders them in order into its compositing buffer, and outputs a single texture. Lower floor content at z:-100 draws first (underneath), current floor at z:0-300 draws over it, upper floor at z:400+ draws on top. The fog shader then draws over the entire composited result. No special handling needed.
### Fog Interaction: Per-Floor Visibility
This is the most subtle part. The fog shader reads a visibility texture that maps tile positions to visibility states. Currently, this texture covers only the current floor. For multi-floor rendering:
**The architectural constraint:** The fog shader maps screen pixels to tile XY coordinates, ignoring floor. A tile at (5, 10) on floor 0 and tile (5, 10) on floor 1 map to the same visibility pixel. If the current floor tile is visible, so is anything below/above at the same XY — which is wrong when there's a solid floor between them.
**Three solutions (design now, implement later):**
1. **Per-floor visibility textures.** Server sends separate visibility data per visible floor. FogState maintains one visibility texture per floor. The fog shader samples the correct texture based on which floor's content is being drawn. This requires the shader to know the floor of each pixel — achievable by encoding floor index in the alpha channel of each floor's content, or by rendering floors in separate passes.
2. **Modulate-based dimming (simplest, recommended for v0.1).** Lower floor content gets visual treatment BEFORE compositing, not via the fog shader. Apply `modulate` on the lower floor parent nodes:
- Floor-1: `modulate = Color(0.4, 0.4, 0.5, 0.7)` — dim, desaturated, semi-transparent
- Floor-2: `modulate = Color(0.25, 0.25, 0.35, 0.5)` — very dim, ghostly
- The fog shader still applies current-floor fog over the composited result
- Tiles outside current-floor LOS get full fog treatment, hiding lower floor content beneath them
- Tiles inside LOS show the dimmed lower floor content (if there's a gap/hole in the current floor)
3. **Combined visibility texture with floor offset.** Single texture with per-floor visibility encoded in separate channels (R = current, G = floor-1, B = floor-2). Shader samples the right channel. Limited to 3-4 floors but compact.
**Recommendation:** Option 2 for initial implementation. It's simple, visually effective (dim = distant floor), and requires zero shader changes. Option 1 for the full implementation when multi-floor rendering is built properly. The z-range allocation works identically either way.
**When there's no hole:** The current floor's ground tiles at z:0 naturally occlude lower floor content at z:-100 to z:-1 (lower z draws first, ground tiles draw over them). Lower floor content is only visible where the current floor has gaps — open railings, missing floor tiles, glass floors, etc. This is automatic from the z-ordering. No special "can see below" flag needed.
### Performance Budget
Per additional visible floor:
| Component | Cost | Notes |
|-----------|------|-------|
| Ground tiles | ~0.1ms | 100-400 tiles, may use simplified tile set |
| Entity sprites | ~0.05ms | 0-15 sprites per floor |
| Y-sort computation | ~0.01ms | Godot internal, trivial |
| Modulate overhead | ~0ms | Single property per parent node |
| Scale transform | ~0ms | GPU matrix multiply, no CPU cost |
| Position update | ~0ms | One Vector2 multiply per frame per floor |
| **Per floor total** | **~0.16ms** | Scale + parallax add no measurable cost |
With +/- 2 window (4 additional floors max): **~0.64ms additional**. Total rendering stays well under budget.
Optimization for implementation: lower floors seen from above don't need full tile detail. A simplified representation (fewer tile variants, possibly lower-res) reduces both rendering cost and art production cost. This is an implementation optimization, not an architecture concern.
### Visual Treatment
Floors below seen from above look different from floors at eye level. This is an art direction question, but the architecture must support it:
**Interior floors (through gap/railing):** See actual floor surface and entity heads. Dim, desaturated (modulate). Entities use standard sprites — a top-down game already shows characters from above.
**Through transparent/grid floors:** Same as above but with grid pattern from the current floor overlaid. The current floor's FloorTiles at z:0 handle this (grid-pattern tile that's partially transparent).
**Rooftops (from exterior):** Flat roof surface sprite, not interior layout. The server/snapshot would include a "viewed from above" flag or the renderer detects floor offset and selects rooftop sprites. This is a different tile set per building type.
**Atriums / open vertical spaces:** Multiple floors visible simultaneously. Each floor at its depth level, progressively dimmer. Entities on each floor y-sorted within their floor group. The +/- 2 window handles 5-floor atriums cleanly.
### Distance Scaling for Depth Illusion
Should lower floors render at progressively smaller scale to fake perspective? E.g., floor-1 at 97%, floor-2 at 94%. This would sell vertical distance visually and create a parallax effect during camera movement.
#### Scale + Y-Sort: Composes Cleanly
In Godot 4, y_sort uses the child's global_position.y after all transforms. If a parent Node2D is scaled, its children's positions are transformed accordingly, but their relative y-ordering is preserved (all children are equally affected by the same parent scale). Cross-floor sorting is handled by z_index (floors don't interleave), so scale on one floor group cannot disrupt another floor's y-sort.
**Confirmed:** Scaling a floor group composes cleanly with y-sort within that group.
#### Scale Center: Player Position
Scaling a Node2D scales around its origin. For a floor group with world-space children, scaling around (0,0) would shift all content toward the origin — wrong. We need to scale around the player/camera position so lower floors appear to recede directly below the player.
Implementation pattern (per frame):
```gdscript
# Scale around player position — creates parallax depth effect
var scale_factor := 0.97 # floor-1
lower_floor.scale = Vector2(scale_factor, scale_factor)
lower_floor.position = player_world_pos * (1.0 - scale_factor)
```
This makes lower floor content "lag behind" camera movement at a reduced rate — a natural parallax depth cue that sells vertical distance effectively.
#### Parallax Effect
Because the camera tracks the player, and lower floors are scaled around the player position, camera movement produces a subtle parallax: lower floor content shifts less than the current floor. This is the most compelling depth cue in a top-down perspective — pure color treatment (dimming/desaturation) cannot produce this spatial effect.
The parallax is proportional to the scale difference:
- Floor-1 at 97% scale: content moves at 97% of camera speed — subtle but perceptible
- Floor-2 at 94% scale: content moves at 94% — clearly different depth plane
#### Performance: Trivial
Scaling a Node2D group is a transform matrix multiplication, applied by the GPU automatically. Cost per floor:
- Position update per frame: one Vector2 multiply + assign (~0ns, CPU)
- GPU transform: included in existing draw call pipeline, no additional cost
- No texture re-rendering, no additional draw calls
Total additional cost for scale: effectively zero.
#### Fog Shader Interaction: No Conflict
The fog shader operates on the COMPOSITED output of the CanvasGroup. The CanvasGroup composites all children (including scaled lower floors) into one texture. The fog shader sees final composited pixels — it doesn't know or care that some content was scaled.
Since we use modulate-based dimming for lower floors (not per-floor fog textures), the slight position offset from scaling doesn't cause fog misalignment. The lower floor's visual treatment is applied before compositing, and the fog shader applies current-floor fog over the composited result.
If per-floor visibility textures are implemented later (Option 1), the UV mapping for lower floor fog would need to account for the scale transform. This is a coordinate correction in the per-floor fog pass — straightforward but worth noting.
#### Readability Cutoff
At the proposed scale values:
- Floor-1 at 97%: fully readable, barely noticeable size difference. Depth is perceived via parallax + dimming.
- Floor-2 at 94%: readable but clearly smaller. Combined with heavy dimming (modulate 0.25 alpha), content becomes atmospheric rather than informational.
This reinforces the +/- 2 visible floor window. Beyond 2 floors, scale would drop below 90%, making content too small for a top-down view. The visibility window and the readability window align naturally.
#### Recommendation: Both Treatments, Layered
Use desaturation/darkening as the PRIMARY depth cue (via modulate — simple, effective, no transform complexity):
- Floor-1: `modulate = Color(0.4, 0.4, 0.5, 0.7)` — dim, cool shift, semi-transparent
- Floor-2: `modulate = Color(0.25, 0.25, 0.35, 0.5)` — very dim, ghostly
Use scale as SECONDARY depth cue (via transform — adds parallax, sells depth spatially):
- Floor-1: `scale = Vector2(0.97, 0.97)` — subtle, most of the parallax effect
- Floor-2: `scale = Vector2(0.94, 0.94)` — more pronounced, atmospheric
Both are applied on the lower floor parent nodes. Combined, they produce a convincing depth illusion:
1. **Color treatment** tells you "this is distant/below" (instant read)
2. **Scale/parallax** tells you "this is a different depth plane" (perceived during camera movement)
3. **Occlusion** by current floor ground tiles tells you "this is underneath" (spatial relationship)
For upper floors seen from below: scale is less important (catwalks above you don't create the same parallax expectation). Use modulate only for upper floor dimming, no scale. This also avoids the visual oddity of overhead content being slightly larger than ground content.
#### Scene Tree Update
The lower floor parent nodes gain scale and position updates:
```
LowerFloor1 (Node2D) z:-100
modulate = Color(0.4, 0.4, 0.5, 0.7)
scale = Vector2(0.97, 0.97)
position = player_pos * 0.03 [updated per frame]
...children...
LowerFloor2 (Node2D) z:-200
modulate = Color(0.25, 0.25, 0.35, 0.5)
scale = Vector2(0.94, 0.94)
position = player_pos * 0.06 [updated per frame]
...children...
```
The per-frame position update is handled by the world renderer (or a dedicated multi-floor manager) alongside the existing camera tracking logic.
### Scene Tree (Multi-Floor, Future)
When multi-floor rendering is implemented, the FogGroup tree extends:
```
FogGroup (CanvasGroup, z:0)
LowerFloor2 (Node2D) z:-200 [dynamic, scale 0.94, modulate dim]
Ground (TileMapLayer) z:0 relative
Objects (Node2D) z:10 relative
Entities (Node2D, y_sort) z:50 relative
LowerFloor1 (Node2D) z:-100 [dynamic, scale 0.97, modulate dim]
Ground (TileMapLayer) z:0 relative
Objects (Node2D) z:10 relative
Entities (Node2D, y_sort) z:50 relative
FloorTiles z:0 [current floor, always present]
FloorObjects z:10 also: ground shadows from HighAirborne
YSortGroup z:100
Airborne z:200 low flyers, per-sprite scale by altitude
Overhead z:300
HighAirborne z:350 above-ceiling flyers, per-sprite scale 1.03-1.30
UpperContent (Node2D, y_sort) z:400 [dynamic, rare with fixed camera]
```
Lower floor nodes are created dynamically when the player enters a location with vertical visibility (balcony, catwalk, atrium). Destroyed when they leave. The parent node's z_index places the entire floor at the correct depth. Child z_index values are relative, so the same sub-structure (ground, objects, entities) works at any depth.
Airborne sprites within the Airborne (z:200) and HighAirborne (z:350) nodes have per-sprite scale set by the entity renderer based on the entity's altitude z_step. High-altitude objects also spawn a ground shadow sprite as a child of FloorObjects (z:10).
### Sprint 6 Impact
None. Lower floor rendering is not implemented in Sprint 6. The architecture reserves z:-200 to z:-1 and defines the contract so future implementation has a clean home. No scene tree nodes needed now.
### Server Requirements (Future)
When multi-floor rendering is implemented, the server needs to provide:
1. **Per-floor visible entities:** ObserverSnapshot includes entities from visible adjacent floors, tagged with floor_index.
2. **Per-floor visible tiles:** Tile data includes floor_index. The renderer groups tiles by floor and assigns to the correct lower/upper floor node.
3. **Floor gap information:** Which tiles on the current floor are transparent/open (allowing view below). This could be a tile property (type: "open_floor", "railing") rather than a separate data field.
4. **Per-floor visibility (for Option 1 fog):** Separate visibility data per floor. The server's LOS algorithm would need to project visibility downward through open floors.
None of these require protocol changes — they're extensions to existing ObserverSnapshot fields. The current protocol already includes floor-aware entity positions (x, y, z fields).
---
## Round 2 Corrections
### Items A-D: Confirmed Defer-Safe
**A. Floor transitions** (player moves between floors): Entity renderer swaps content per floor. No z-layer changes. The LowerFloor/UpperContent nodes are created/destroyed dynamically. Nothing in the z architecture blocks this — it's a script problem (entity renderer grouping by floor_index).
**B. Cursor on modals**: Cursor lives in UILayer (CanvasLayer 20). Modals live in ModalLayer (CanvasLayer 30). When a modal opens, cursor either (a) moves to ModalLayer temporarily, (b) ModalLayer spawns its own cursor, or (c) cursor CanvasLayer number bumps above modal. All are script-level solutions. The z-layer architecture accommodates any of these — CanvasLayers are independent.
**C. Half-wall occlusion**: Walls in YSortGroup at z:0, y-sort handles front/back occlusion naturally. Half-height walls use semi-transparent sprites. Entities behind half-walls: upper body occluded, lower body visible — achieved via sprite masking or split sprites (top half at wall z, bottom half visible). This is an art/shader problem. Nothing in the z architecture blocks it.
**D. Glass floors / forcefields**: Semi-transparent tiles at z:0 (ground plane). Lower floor content at z:-100 is visible through them. The z-ordering naturally handles this — lower floor renders first, semi-transparent ground renders over it, entities render on top. Forcefields could be FloorObjects (z:10) or YSortGroup members (z:100) depending on whether they're walked-over or blocking. Standard transparency, no architecture changes.
**Confirmation: nothing in the current z-layer architecture blocks any of A-D.** All are script, shader, or art problems that work within the established ranges and contracts.
### Item E: Airborne Ascending Alpha Fade
Lead's proposal: ascending objects get bigger (scale > 1.0, closer to camera) AND more transparent (fading out of active layer). A drone taking off fades as it ascends, eventually becoming just a ground shadow.
**Combined formula:**
```gdscript
var altitude_floors := (entity.z_step - current_floor_z_step) / 5.0
var scale_factor := 1.0 + altitude_floors * 0.03 # 1.0 → 1.30
var alpha := clampf(1.0 - (altitude_floors / 10.0), 0.0, 1.0) # 1.0 → 0.0
airborne_sprite.scale = Vector2(scale_factor, scale_factor)
airborne_sprite.modulate.a = alpha
```
Visual progression:
| Altitude (floors) | Scale | Alpha | Visual |
|-------------------|-------|-------|--------|
| 0 (ground) | 1.00 | 1.00 | Normal entity, on ground |
| 1 | 1.03 | 0.90 | Slightly larger, barely fading |
| 3 | 1.09 | 0.70 | Noticeably larger, starting to ghost |
| 5 | 1.15 | 0.50 | Large and semi-transparent |
| 8 | 1.24 | 0.20 | Very large, ghostly, barely visible |
| 10 (ceiling) | 1.30 | 0.00 | Invisible — only ground shadow remains |
This is elegant. The object visually "passes through" the camera plane — growing as it approaches, fading as it passes. The ground shadow (at z:10, FloorObjects) persists as the last trace.
**CanvasGroup composition: clean.** Per-sprite `modulate.a` works correctly inside a CanvasGroup. The CanvasGroup renders children in z-order into its compositing buffer with proper alpha blending. A sprite at modulate.a = 0.3 composites as semi-transparent in the output texture. Multiple semi-transparent airborne sprites that overlap would blend correctly (rendered in z-order within the Airborne/HighAirborne nodes).
**No conflict with lower floor modulate.** Lower floor depth treatment uses modulate on the PARENT node (LowerFloor1, LowerFloor2). Airborne alpha uses modulate on INDIVIDUAL sprites within Airborne/HighAirborne nodes. These are completely separate node hierarchies — the per-sprite alpha doesn't interact with the per-floor-group modulate. In Godot 4, modulate multiplies down the tree: a sprite with modulate.a = 0.5 under a parent with modulate.a = 0.7 renders at effective alpha 0.35. But airborne sprites are NOT children of lower floor groups — they're children of Airborne (z:200) or HighAirborne (z:350), whose parent is FogGroup (modulate = default 1.0). No unintended multiplication.
**Descending objects (landing):** The inverse — object descends from high altitude, scale shrinks from 1.30 toward 1.0, alpha increases from 0.0 toward 1.0. At ground level: normal size, fully opaque. Smooth transition. The same formula works bidirectionally.
**Contract update:** Airborne sprites have two per-sprite properties driven by altitude:
1. `scale` — increases with altitude (1.0 to 1.30)
2. `modulate.a` — decreases with altitude (1.0 to 0.0)
3. Ground shadow sprite (child of FloorObjects z:10) — opacity inversely proportional to airborne alpha (shadow gets stronger as object gets higher/more transparent)
### Item F: Cross-Floor Visual Effects (Smoke, Water, Gas)
Smoke rising through floor grates, water dripping between levels, gas leaks spreading across floors. These are CROSS-FLOOR effects that visually span multiple floor groups.
**Key insight: the FogGroup CanvasGroup composites EVERYTHING in z-order.** Cross-floor effects don't need to be children of any specific floor group. They can be standalone nodes at intermediate z values within the FogGroup. The per-floor render group structure does NOT block cross-floor effects — it actually enables them.
**How it works:**
A smoke plume rising from floor 0 through the ceiling:
1. Smoke origin particles at z:150 (between YSortGroup z:100 and Airborne z:200) — visible above ground entities, below flying objects
2. Smoke mid-section at z:250 (between Airborne z:200 and Overhead z:300) — rising through air space
3. Smoke passing through ceiling at z:325 (between Overhead z:300 and HighAirborne z:350) — partially occluded by ceiling semi-transparency
The effect spans multiple z ranges within the same FogGroup compositing pass. From the CanvasGroup's perspective, it's just more children at various z values — composited in order with everything else.
**Water dripping between levels:**
1. Water source on current floor at z:100 (YSortGroup — a pipe, a ceiling leak target)
2. Water particles falling downward: these would be in the lower floor range (z:-50 to z:-1) — visible through floor gaps, just like lower floor entities
3. Water splash on lower floor at z:-50 (LowerFloor1's entity range)
**Gas spreading across floors:**
1. Gas origin on one floor
2. Gas particles at intermediate z values (z:150, z:250) for current floor
3. Gas seeping downward through floor gaps: particles in z:-50 to z:-1 range
4. Gas rising upward: particles in z:250 to z:325 range
**Reserved VFX z-ranges:**
The existing pipeline has natural gaps between functional ranges. These gaps are the VFX home:
```
z:-75 to z:-51 Lower floor VFX (effects between lower floor entities and current ground)
z:150 to z:199 Ground-level VFX (smoke starting, gas pooling, sparks from ground)
z:250 to z:299 Mid-air VFX (rising smoke, floating particles, air effects)
z:325 to z:349 Ceiling-level VFX (smoke passing through ceiling, overhead effects)
```
These ranges are already available — they're gaps between the established functional z values. No architecture changes needed. Just a reservation in the contract so future implementers know where VFX nodes belong.
**CanvasGroup consideration:** All VFX nodes are inside FogGroup. Particle effects with transparency composite correctly in the CanvasGroup buffer. Semi-transparent smoke at z:250 blends with the Airborne content at z:200 and Overhead at z:300 naturally. The fog shader then applies fog over the entire composited result — fogged areas hide VFX just like they hide entities.
**Performance:** Particle effects are GPU-driven in Godot 4 (GPUParticles2D). A few hundred particles across 3-4 VFX emitters: ~0.1-0.2ms. Well within budget.
**Per-floor modulate interaction:** VFX nodes at intermediate z values are NOT children of floor groups. They're siblings in the FogGroup. Their modulate is independent. A smoke plume at z:250 is fully opaque regardless of LowerFloor1's modulate at z:-100. This is correct — you see the smoke at full intensity, even if the floor below is dimmed.
**Confirmation: nothing in the current z-layer architecture blocks cross-floor VFX.** The per-floor group structure and intermediate z gaps actually make it clean — effects slot into the gaps between functional ranges, composited by the CanvasGroup alongside everything else. The only reservation needed is documenting the VFX z-ranges in the contract.
---
## Updated Complete Pipeline (Full Range)
```
z:-200 to z:-101 Floor-2 below [future, scale 0.94, modulate dim]
z:-100 to z:-76 Floor-1 below [future, scale 0.97, modulate dim]
z:-75 to z:-51 Lower floor VFX [future] effects between lower floor + current ground
z:-50 to z:-1 Floor-1 entities/air [future] lower floor y-sort content
z:0 FloorTiles current floor ground
z:10 FloorObjects cosmetic + ground shadows from high flyers
z:100 YSortGroup current floor y-sort (entities/furniture/walls)
z:150 to z:199 Ground VFX [future] smoke origins, gas pools, ground sparks
z:200 Airborne low-flying objects, scale+alpha by altitude
z:250 to z:299 Mid-air VFX [future] rising smoke, floating particles
z:300 Overhead ceiling, upper structure, semi-transparent
z:325 to z:349 Ceiling VFX [future] smoke through ceiling, overhead effects
z:350 HighAirborne above-ceiling flying, scale+alpha by altitude
z:400 UpperContent upper-floor entities (rare, fixed camera)
z:500-899 Reserved edge cases
z:900 FogOverlay OUTSIDE FogGroup, fog shader
CanvasLayer 10 InsertOverlay interaction UI, perception markers
CanvasLayer 20 UILayer HUD, monologue, cursor
CanvasLayer 30 ModalLayer full-screen modals, pause menu
```
**Total z range:** -200 to 900 (of -4096 to 4096 available).
**Visible floor window (looking down):** current - 2 (detailed rendering with scale + parallax).
**Rendering ceiling (looking up):** current + 10 floors (25m). Above: no sprites, ground shadows only.
**Airborne treatment:** scale increases + alpha decreases with altitude. Ground shadow at z:10.
**VFX ranges:** Reserved at z gaps between functional layers. Cross-floor effects via intermediate z values.
---
---
## Quick Note: Liquid Depth and Z-Layers
Liquid depth at 0.5m sub-levels (wading → struggling → swimming) composes cleanly with the z-layer architecture. No changes needed — it fits in existing and reserved ranges.
### Where Liquid Renders
Water has TWO render layers, not one:
1. **Water bed** at z:10 (FloorObjects) — tinted/darkened floor beneath water. Always present when water exists. Shows underwater color, murk, submerged objects.
2. **Water surface occlusion** at z:110 (NEW range, between YSortGroup z:100 and Ground VFX z:150) — semi-transparent layer that partially covers entities. Opacity scales with depth:
- Sub 1 (0.5m, wading): alpha ~0.15 — faint shimmer, entity legs slightly obscured
- Sub 2 (1.0m, struggling): alpha ~0.45 — entity lower body submerged, water clearly visible
- Sub 3 (1.5m, swimming): alpha ~0.75 — entity mostly submerged, only head/shoulders visible
3. **Water VFX** (ripples, splashes, shimmer) at z:150-199 (existing Ground VFX reservation) — particle effects on the water surface.
### Y-Sort Interaction
The water surface at z:110 sits ABOVE the entire YSortGroup (z:100). It covers all entities on that tile uniformly regardless of y-position. This is correct — water depth is uniform across a tile, not positional. An entity at y=3 and an entity at y=8 on the same flooded tile are equally submerged.
Entities do NOT y-sort with water. Water is a surface, not an object with a y-position. The z-layer separation handles this naturally: entities y-sort with each other (z:100 group), then water surface draws over all of them (z:110).
### Rising Water Transition
Rising water doesn't move through z-ranges. It stays at the same z values — only the OPACITY of the z:110 surface layer changes:
```
Dry → Sub 1: z:10 appears (water bed color), z:110 appears (alpha 0.15)
Sub 1 → Sub 2: z:10 darkens, z:110 alpha increases to 0.45
Sub 2 → Sub 3: z:10 deeper color, z:110 alpha increases to 0.75
Sub 3 → full: z:110 alpha 0.95, entities nearly invisible, only swimming animation above
```
The transition is a smooth alpha tween on the surface layer, not a z-range change.
### Entity Sprite Consideration
At sub 2-3, entities should ideally show partial submersion (legs hidden, body partially in water). Two approaches:
**A. Sprite masking (shader):** Entity sprite shader clips pixels below a water-line y-offset. The water surface at z:110 then covers the clipped area. Clean but requires per-entity shader.
**B. Opacity-only (simpler):** Don't clip entity sprites. The semi-transparent water surface at z:110 just tints/obscures the lower portion naturally. Less precise but simpler. At 75% water alpha (sub 3), entity legs are heavily obscured without needing a clip mask.
Recommendation: Option B for v0.1, Option A as polish. The z-layer architecture supports both — it's an entity shader question, not a z-layer question.
### Pipeline Impact
One new reserved z-range:
```
z:100 YSortGroup entities/furniture/walls
z:110 LiquidSurface [future] water/liquid occlusion, alpha by depth
z:150-199 Ground VFX smoke, ripples, splashes
```
No architecture changes. z:110 is available in the existing gap. Cross-floor liquid effects (water dripping down) already covered by lower floor VFX ranges (z:-75 to z:-51).
---
## Verdict (Updated — Round 2)
**No remaining architectural gaps.** The full rendering pipeline is now specified:
- Fixed top-down camera (D-019) simplifies upper range: no "looking up," upper floors only via horizontal offset or transparency
- Negative z (-200 to -1) for lower floors with scale < 1.0 + parallax (depth illusion)
- Positive z (0-900) for current floor, airborne, overhead, high-altitude flying, upper content
- Flying objects INCREASE in scale AND DECREASE in alpha with altitude — smooth "passing through camera" effect
- Airborne split: z:200 (low, below ceiling) and z:350 (high, above ceiling) — different draw order relative to Overhead
- Rendering ceiling: 10 floors / 25m / z_step+50 — above this, no sprites, ground shadows + environmental effects only
- Per-sprite modulate.a on airborne objects composes cleanly with CanvasGroup (no conflict with per-floor modulate)
- Cross-floor VFX (smoke, water, gas) slot into reserved z gaps between functional ranges — architecture enables, not blocks
- VFX ranges reserved: z:-75 to -51, z:150-199, z:250-299, z:325-349
- Items A-D (floor transitions, cursor on modals, half-wall occlusion, glass/forcefields) confirmed defer-safe — no architecture blockers
- CanvasGroup composites all floors + VFX correctly (negative z first, positive z after)
- Fog applies via modulate per floor (v0.1) or per-floor visibility textures (v0.2+)
- Current floor occlusion is automatic from z-ordering (ground at z:0 covers z:-100 content)
- Visible floor window (down): current - 2 (detailed, scale + parallax, ~0.64ms budget)
- Y-sort contract preserved: each floor has its own y-sort group, no cross-floor interleaving
- Dual depth cues for lower floors: modulate (color) + scale (parallax), composes cleanly with y-sort
- Three CanvasLayer scopes defined (Insert 10, UI 20, Modal 30)
- Critical bug: Entities z:3 must become z:0
Ready to implement Sprint 6 changes (scene tree z_index corrections + constants.gd rewrite) on lead's go.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# Scratchpad
Personal notes and random thoughts. Not acted upon unless explicitly instructed.
---
- Pipeline to create postcards for each world — visuals for in-game dossiers
- Investigate using Veo to create gate transition movies — based on planetary profiles, postcards, and game visual style
- Capture discussion with Gemini about setting up a 3D to 2D pipeline
+111
View File
@@ -0,0 +1,111 @@
# Sprint 6: Touch — Client Tasks
**Goal:** Movement stances, fog rebuild, multi-verb interactions, and smuggler inventory
**Branch:** `client`
**Agents:** Stig (UI/rendering), Tyre (architecture), Hoshe (QA)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #429 | Cursor state machine — 4 states, Araminta spec, 150ms transitions | — |
| #430 | Fog shader rebuild — 5-layer, CanvasGroup Layer 5, animated noise | — |
| #432 | Entity interaction vertical list — insert-styled, z-layer 6 | #429 |
| #433 | World radial menu — 2 spokes v0.1 (Observe + Insert) | — |
| #438 | Inventory UI — 3x3 grid, 1-9 hotkeys | #449 (server) |
| #439 | Stance toggle UI — keybind + HUD indicator | #449 (server) |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/perception.md` — D-056 (Cursor states), D-057 (Entity interaction vertical list), D-058 (World radial menu), D-059 (Fog shader rebuild), D-061 (Dialogue box)
- `decisions/scope.md` — D-053 (Movement stances), D-065 (Smuggler inventory)
- `decisions/architecture.md` — D-049 (Z-level rendering stack)
## Open Questions Resolved
- **OQ-24: Inventory capacity** — Resolved to 3x3 grid = 9 slots (ticket #445, done)
## Notes
### #429: Cursor state machine — 4 states
- **What exists:** Basic cursor rendering in Godot (default arrow)
- **Needs:** Implement 4-state cursor using D-048 insert geometric aesthetic (thin ticks, bloom shader):
- **Default:** Four thin inward-pointing ticks with bloom. White-blue #c8d0e0. Barely visible.
- **Entity hover:** Ticks expand outward (150ms), corner brackets frame entity, color shifts to D-033 relationship color, verb tooltip in insert styling, bloom pulse ~10% brighter on entity outline
- **Object hover:** Ticks rotate 45° to X-shape, muted grey #8b8ba0 (amber #e8c547 if flagged), simpler frame than entity
- **Weapon aim:** Hard transition. Ticks extend, center gap widens, lines thicken 1→2px, warm white #f0e8d8, NO bloom. Entity in sights tints to D-033 color (aiming at friend = green tint should feel wrong).
- **D-056 spec:** All transitions 150ms linear. z-layer 7. Never changes by zone/narrative state (D-045). Cursor changes on LOS, not just proximity. Click interaction range: ~2 tiles. Weapon-selected mode suppresses interaction prompts unless Shift held.
- **Diegetic test (Stig):** Interaction labels render on z-layer 6 (insert overlay). If insert is off, labels disappear.
- **Integration:** Blocks #432 (entity interaction list — cursor states trigger list display)
### #430: Fog shader rebuild — 5-layer from scratch
- **What exists:** `client/scripts/rendering/fog_renderer.gd` extends TileMapLayer with 2-state fog (opaque black, semi-transparent edge). Lines 1-50 show current implementation.
- **Needs:** **REBUILD from scratch.** Replace TileMapLayer-based fog with CanvasGroup + fragment shader. Five distinct layers:
1. **Clear (vision cone):** Soft gradient edge 3-4 tiles (Darkwood approach), no hard line
2. **Light fog (peripheral):** Desaturated 40-50%, brightness -30%, animated Perlin noise overlay (8-10s cycle), entity D-033 colors visible but reduced
3. **Deep fog (previously explored):** Near-monochrome with ~10% "zone temperature" tint (bar=warm dark, hub=cool dark, corridor=neutral dark), more pronounced noise (15-20s cycle), fog breathes
4. **Unexplored + maps app:** Geometric wireframe outlines #333340 (insert data aesthetic)
5. **Unexplored, no maps:** Solid near-black #12141a (information zero)
- **Fog entities (sprites on Layer 5):**
- **Sound pings:** 2-3 thin concentric expanding rings (sonar-style) from source direction, insert white-blue. Loud = 3 rings bright fast. Quiet = 1 ring faint slow. Fade over 1.5s.
- **Recognized entity:** D-033 color glow + faint identifying silhouette feature (e.g., "Kael's vest") + 0.8s breathing pulse + ±0.5 tile position drift (approximate, not exact). Recognition transition governed by D-060 cognitive delay.
- **Unrecognized entity:** Neutral grey #555566 blob. No silhouette, no identifying features.
- **D-059 spec:** Knowledge-graph-driven — same fog shows different information per character based on their KG. Smuggler recognizes dock workers = green icons. Detective sees same entities = grey blobs. "Fog is not darkness — it's the absence of your attention."
- **Performance target:** <1ms/frame total. Vision cone = PointLight2D. Fog = natural darkness + noise shader on CanvasGroup (Layer 5). Sound pings = 0-5 sprites. Entity ghosts = 0-10 sprites typical.
- **Gotcha:** This is a complete rewrite, not an enhancement. Delete `fog_renderer.gd` and start fresh with shader-based approach. Araminta's spec in D-059 is the source of truth.
- **Integration:** Blocks #431 (fog entity visualization, deferred to Sprint 7+)
### #432: Entity interaction vertical list — insert-styled
- **What exists:** Interaction prompt system (#405, done in Sprint 5) shows "E: <verb>" on nearest entity
- **Needs:** Replace single-verb prompt with compact vertical list (2-4 options max), anchored to entity position, insert-styled with Araminta's geometric aesthetic (thin lines, bloom, z-layer 6). New options unlocked by KG changes highlighted with gradient glow background. Max 3 visible response options in dialogue context.
- **D-057 spec:** Variable-length text options (confrontation lines in character voice) break radial spatial memory. List handles 1-4 options cleanly. Radial reserved for world menu only (#433).
- **Server integration:** Reads `nearby_interactions` from ObserverSnapshot (Phase 2 filtered verbs per D-057). Server provides 2-4 verbs, client renders list. Clicking a verb sends PlayerAction::Interact with target + verb.
- **Diegetic test:** Labels render on z-layer 6. If insert is off, labels disappear (passes "is this information from character's implant?" test).
- **Integration:** Blocked by #429 (cursor states — entity hover triggers list display). Works with server #422 (two-phase verb computation).
### #433: World radial menu — 2 spokes v0.1
- **Needs:** Right-click opens radial world menu. v0.1: 2 spokes only (Observe + Insert). Insert-styled: geometric lines, thin spokes with icons (eye icon for Observe, phone icon for Insert), nearly transparent. Renders on z-layer 6. Drag-release for power users (high-speed drag-to-select), click-click for newcomers. Future: scale to 4 spokes (add Comms, Wait).
- **D-058 spec:** Radial works for world menu because items are fixed categories that don't vary by knowledge state (unlike entity verbs which use vertical list #432). Spatial memory builds quickly (~10 minutes).
- **Integration:** Standalone. Complements #432 (entity list vs world radial — different UX for different contexts).
### #438: Inventory UI — 3x3 grid, 1-9 hotkeys
- **Needs:** Bottom-right HUD area, 3x3 grid (40x40px icons), mapped to 1-9 keys. No empty slots displayed — icons appear only when items are carried. Reads `player_inventory` from ObserverSnapshot v6 (server #449).
- **D-065 spec:** OQ-24 resolved to 9 slots universal (3x3 grid). Smuggler gets 3 items in v0.1 (manifest copy, access token, comm log). Detective gets 2 slots used. Items are world entities with CarriedBy component (server-side).
- **UI positioning:** Bottom-right, does not overlap dialogue box (max 20% height per D-061). Icons render at z-layer 7 (HUD elements).
- **Integration:** Blocked by server #449 (ObserverSnapshot v6 adds player_inventory field). Works with server #424 (CarriedBy component + Take/Place verbs).
### #439: Stance toggle UI — keybind + HUD indicator
- **Needs:** Keybind for stance toggle (suggest C key, or Ctrl as ladder climb), HUD indicator showing current stance (icon + text: "Sprint" / "Walk" / "Careful" / "Crouch"). Reads `player_stance` from ObserverSnapshot v6 (server #449). Visual feedback on stance change (brief highlight or fade).
- **D-053 spec:** Toggle-based ladder: Sprint → Walk → Careful → Crouch → (Prone future). Each stance affects movement speed and monologue rate. v0.1: 4 stances (Sprint/Walk/Careful/Crouch).
- **UI positioning:** Top-left or top-right HUD area (not overlapping dialogue box or inventory). z-layer 7.
- **Integration:** Blocked by server #449 (ObserverSnapshot v6 adds player_stance field). Works with server #417 (stance system).
## Dependency Chain
```
Critical path:
#429 (Cursor states) → #432 (Entity interaction list)
Parallel tracks:
#430 (Fog shader rebuild) → standalone, large rewrite
#433 (World radial menu) → standalone
#438 (Inventory UI) → blocked by server #449
#439 (Stance UI) → blocked by server #449
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(client): cursor states, fog shader rebuild, interaction list, inventory + stance UI" \
--description "Sprint 6 client deliverables per D-056, D-057, D-058, D-059, D-061, D-065" \
--base main \
--head client
```
+81
View File
@@ -0,0 +1,81 @@
# Sprint 6: Touch — Copy Tasks
**Goal:** Movement stances, fog rebuild, multi-verb interactions, and smuggler inventory
**Branch:** `copy`
**Agents:** Mellanie (narrative), Paula (systems), Gestalt (content architecture)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #441 | Define 3 smuggler item specs — manifest copy, access token, comm log | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/scope.md` — D-065 (Smuggler inventory — knowledge-primary with physical evidence)
- `decisions/content.md` — D-034 (THE FRIEND — production-level NPC pattern)
## Notes
### #441: Define 3 smuggler item specs
- **What exists:** Knowledge graph tracks what player knows (KG infrastructure from Sprint 3-4). Physical inventory is new for Sprint 6.
- **Needs:** Author YAML item specs for 3 smuggler inventory items (per D-065):
1. **Manifest copy** — Proves cargo discrepancy. Leverage proof (smuggler's word doesn't carry institutional weight).
2. **Corridor access token** — Proves ring membership. Physical proof of social network position.
3. **Personal comm log** — Bridge between KG knowledge and provable leverage. Recorded conversations.
- **D-065 context:** Knowledge is the primary "inventory" for all characters (you SAW the manifest, not you HAVE it). Smuggler additionally gets minimal physical inventory (3 items in v0.1) because the smuggler's word doesn't carry institutional weight — they need tangible proof for leverage. Detective's word IS evidence (institutional authority), so detective is mostly KG-only (2 slots reserved for future use).
- **Content requirements per item:**
- **Name:** Short display name (e.g., "Manifest Copy", "Access Token")
- **Description:** 1-2 sentence item description in character voice (smuggler perspective)
- **Flavor text:** Optional 1-line contextual note (e.g., "Kael's handwriting on the margins")
- **Knowledge link:** Which FactId(s) this item proves or corroborates (e.g., manifest copy proves `CargoDiscrepancySova17`)
- **Contraband flag:** Boolean — is this item detectable by security scans? (future #425)
- **File location:** `content/campaigns/main/systems/krenn/sova/sova-transit/items/smuggler-inventory.yaml` or similar
- **Cross-reference:** Sprint 5 deliverables (#297 Kael FRIEND pack, #298 Sera FRIEND pack) provide context for smuggler's social network and cargo operations. Items should reference established content.
- **Integration:** Server #424 implements CarriedBy component + Take/Place verbs. Client #438 renders inventory UI. Copy team defines the content (what the items are, why they matter).
## Dependency Chain
```
#441 (Smuggler item specs) → standalone
Server #424 (CarriedBy implementation) reads item specs
Client #438 (Inventory UI) displays items
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(content): smuggler inventory item specs" \
--description "Sprint 6 copy deliverables per D-065 — manifest copy, access token, comm log" \
--base main \
--head copy
```
## Notes for Copy Team
Sprint 6 is light for copy — only 1 ticket (#441). This is intentional: Sprint 5 was heavy content authoring (FRIEND packs ~150-200 lines), Sprint 6 focuses on systems and UI (server/client carry the load).
Item specs are small but thematically important. These items differentiate the smuggler's gameplay loop from the detective's. The smuggler needs physical proof because their word doesn't carry authority. Each item should feel like a risk to carry and a weapon to wield.
**Voice and perspective:**
- Items are viewed through the smuggler's eyes. Descriptions should reflect the smuggler's relationship to the object (e.g., "Kael slipped me this token three weeks ago. Access to the maintenance corridors. Hope it still works.").
- Item descriptions are diegetic — the character is looking at the item in their inventory. Not neutral game text.
**Knowledge integration:**
- Each item should tie to at least one FactId from the knowledge graph. For example, the manifest copy might prove `CargoDiscrepancySova17` (a fact the smuggler knows) and unlock leverage in confrontation with Kael or Sera.
- Paula and Gestalt: cross-check item specs against existing KG FactIds in `content/global/knowledge/` to ensure consistency.
**Contraband implications:**
- All 3 items are contraband (security would flag them). This is thematically consistent with the smuggler archetype. Detective doesn't carry contraband by default (institutional authority protects them).
- Contraband detection is deferred to future sprint (#425), but flag items now so the data is ready.
Sprint 6 copy work is small, high-quality, thematically load-bearing. Treat these 3 items as miniature character studies — what the smuggler carries defines who they are.
+253
View File
@@ -0,0 +1,253 @@
# Sprint 6: Touch — Joint Integration
**Goal:** Movement stances, fog rebuild, multi-verb interactions, and smuggler inventory
**Teams:** Server, Client, Copy
**Agents:** All implementation agents (Dudley, Stig, Tyre, Hoshe, Mellanie, Paula, Gestalt)
## Sprint Overview
Sprint 6 is the control and interaction sprint. Sprint 1-5 built the simulation, the bridge, the rendering pipeline, the content loading, and the FRIEND NPCs. Sprint 6 delivers the player's direct interface to the world: how they move (stances), how they see (fog shader), how they interact (multi-verb system), and what they carry (inventory).
This sprint emerged from the **Control & Interaction Workshop** (2026-02-13), which produced 11 decisions (D-053 through D-065) and 33 tickets under epic #416. Sprint 6 implements the critical-path subset: 18 tickets across server, client, and copy teams.
## Team Distribution
| Team | Ticket Count | Load |
|------|--------------|------|
| Server | 9 tickets | Heavy (stance system, tile occupancy, two-phase verb computation, smuggler inventory, bridge v6) |
| Client | 6 tickets | Heavy (cursor states, fog shader rebuild, interaction list, world radial menu, inventory UI, stance UI) |
| Copy | 1 ticket | Light (3 smuggler item specs) |
## Pre-Sprint Requirements
Two open questions were resolved during the workshop and ticket creation:
- **OQ-01: Tile size** — Resolved to 0.5m x 0.5m sim tiles (ticket #444, done). Amended by D-066: dual-scale grid with 1m visual tiles (2x retina factor). All world geometry 2x2 sim tile minimum.
- **OQ-24: Inventory capacity** — Resolved to 3x3 grid = 9 slots universal (ticket #445, done)
All blocking decisions are confirmed. No pre-sprint work required.
## Critical Path
Sprint 6 has three parallel tracks with one critical integration point:
```
Server track (stance + movement):
#449 (ObserverSnapshot v6) → client #438 (inventory UI), client #439 (stance UI)
#417 (Stance system)
├─→ #418 (MovementProfile)
├─→ #419 (Sprint suppression)
└─→ #420 (TilePresence, parallel)
Server track (interaction verbs):
#421 (ObjectType) → #422 (Two-phase verb computation) → client #432 (interaction list)
Server track (inventory):
#424 (Smuggler inventory) → client #438 (inventory UI)
Copy #441 (item specs) → informs server #424 content
Client track (cursor + fog):
#429 (Cursor states) → #432 (interaction list)
#430 (Fog shader rebuild) → standalone, large rewrite
#433 (World radial menu) → standalone
Client track (UI):
#438 (Inventory UI) → blocked by server #449
#439 (Stance UI) → blocked by server #449
```
**Critical integration point:** Server #449 (ObserverSnapshot v6) unblocks client #438 and #439. Server #417 (stance system) and #424 (inventory) must land before client UI can integrate.
## Cross-Team Integration Points
### Server → Client: ObserverSnapshot v6 (#449)
**Server deliverable:** Add two new fields to ObserverSnapshot (bridge wire protocol):
- `player_stance: MovementStance` (enum: Sprint/Walk/Careful/Crouch)
- `player_inventory: Vec<VisibleItem>` (items carried by player)
**Client consumers:**
- #438 (Inventory UI) reads `player_inventory` to render 3x3 grid
- #439 (Stance UI) reads `player_stance` to show HUD indicator
**Timeline:** Server #449 should land early in sprint (day 1-2) to unblock client UI work. Client can scaffold UI in parallel, but cannot test until #449 lands.
**Coordination:** Dudley (server) and Stig (client) coordinate on wire format. Use existing ObserverSnapshot pattern (see `server/src/bridge/types.rs` line 29-48 for current v5).
### Server → Client: Two-phase verb computation (#422#432)
**Server deliverable:** Refactor interaction verb computation into Phase 1 (ObjectType → max verb set) and Phase 2 (observer KG filter → final verbs). Populate `nearby_interactions` in ObserverSnapshot with 2-4 filtered verbs per entity.
**Client consumer:** #432 (entity interaction list) reads `nearby_interactions` and renders vertical list. Clicking a verb sends PlayerAction::Interact with target + verb.
**Timeline:** Server #421 (ObjectType) + #422 (two-phase) must land before client #432 can integrate. Client can build UI skeleton in parallel (test with mock data).
**Coordination:** Dudley (server) defines verb wire format (verb name, display text, tooltip). Stig (client) consumes format and renders insert-styled list per Araminta's D-056/D-057 spec.
### Copy → Server: Smuggler item specs (#441#424)
**Copy deliverable:** YAML item specs for 3 smuggler items (manifest copy, access token, comm log). Each item includes name, description, flavor text, knowledge link (FactId), contraband flag.
**Server consumer:** #424 (smuggler inventory) reads item specs, spawns items as world entities with CarriedBy component, implements Take/Place verbs.
**Timeline:** Copy #441 can land anytime during sprint (small authoring task, ~2-3 hours). Server #424 can proceed without item specs (use placeholder data), then swap in real content once copy delivers.
**Coordination:** Paula (copy) and Dudley (server) agree on YAML schema format early in sprint. Server defines schema, copy authors content.
## Sprint Completion Proof
At the end of Sprint 6, the following must be observable in-game:
### Movement and Stances
1. **Stance toggle works:** Press C (or Ctrl) to cycle through Sprint → Walk → Careful → Crouch. HUD indicator updates (#439).
2. **Movement speed changes:** Sprint = 1 tile/1 tick (fast). Walk = 1 tile/2 ticks (default). Careful = 1 tile/3 ticks (slow). Observable by walking across 10 tiles and counting ticks.
3. **Monologue rate changes:** Sprint suppresses monologue (fewer lines). Careful enhances monologue (more lines, enhanced observation). Observable by changing stances in monologue-rich area (bar, hub).
4. **Sprint suppresses interactions:** While sprinting, interaction prompts disappear (no verb list shown, even when hovering over NPC). Walk/Careful/Crouch restore interactions.
5. **Same-tile occupancy works:** Two NPCs can occupy the same tile if one is Standing and one is Seated/Prone. Collision detection prevents two Standing entities on same tile.
### Fog and Visibility
6. **Fog shader renders:** Launch game, observe 5-layer fog system:
- Clear cone with soft gradient edge (no hard line)
- Light fog (peripheral) with animated Perlin noise, desaturated colors
- Deep fog (previously explored) with zone temperature tint (bar=warm, corridor=neutral)
- Unexplored (with maps app) shows wireframe outlines
- Unexplored (no maps) shows solid near-black
7. **Fog breathes:** Noise animation cycles over 8-10s (light fog) and 15-20s (deep fog). Fog feels alive, not static.
### Interaction and Verbs
8. **Cursor states work:** Hover over empty tile = default ticks. Hover over NPC = entity hover (ticks expand, D-033 color). Hover over object = X-shape grey cursor. All transitions 150ms.
9. **Entity interaction list renders:** Hover over NPC with multiple verbs → vertical list appears (2-4 options max), anchored to entity, insert-styled. Click verb → PlayerAction::Interact sent to server.
10. **World radial menu works:** Right-click anywhere → radial menu opens with 2 spokes (Observe + Insert). Drag-release or click-click to select. Renders on z-layer 6.
11. **Two-phase verb computation:** Same object shows different verbs per character. Smuggler hovers over crate → sees "Move/Stash". Detective hovers → sees "Scan/Flag". Character-archetype differentiation via KG filter.
### Inventory
12. **Inventory UI renders:** Smuggler character → bottom-right 3x3 grid appears. Icons for 3 items (manifest copy, access token, comm log). Detective character → 2 items (or empty if no items carried). Press 1-9 keys → item selection feedback.
13. **Take/Place verbs work:** Approach item on ground → interaction list shows "Take". Click Take → item disappears from world, appears in inventory UI. Inventory icon click + right-click world → "Place" verb appears. Click Place → item returns to world.
14. **Info boundary respected:** Items in smuggler's inventory are NOT visible to NPCs (no "What are you carrying?" monologue unless confrontation/scan mechanic triggered, deferred to future sprint #425).
### Integration
15. **ObserverSnapshot v6 wire works:** Client receives `player_stance` and `player_inventory` fields from server. HUD updates reflect server state (no desyncs).
16. **All systems compose:** Stance + fog + verbs + inventory all work simultaneously. No system breaks another.
### Validation Tests
**Simulation:**
```bash
# Stance system tests
cargo test simulation::movement::stance
# Tile occupancy tests
cargo test simulation::movement::tile_presence
# Two-phase verb computation tests
cargo test simulation::interaction::two_phase
# Inventory tests
cargo test simulation::interaction::carried_by
```
**Client rendering:**
```bash
# Godot client launches without errors
cd client && godot --headless --quit
# Shader compilation (fog shader)
# Manual test: launch game, observe fog rendering with no errors in console
```
**Content validation:**
```bash
# Smuggler item specs validate
make validate-content
# Item YAML loads in server
cargo run --bin line-previewer -- content/campaigns/main/systems/krenn/sova/sova-transit/items/smuggler-inventory.yaml
```
## Test Plan Alignment (D-030)
Sprint 6 is in **Phase 2** (integration tests) transitioning to **Phase 3** (simulation depth).
Key test requirements:
**Phase 2 (integration):**
- Bridge protocol test: ObserverSnapshot v6 serializes/deserializes correctly with new fields
- Interaction pipeline test: PlayerAction::Interact → verb computation → ObserverSnapshot update → client rendering
- Movement integration: stance toggle → movement speed change → monologue rate change (full pipeline)
**Phase 3 (simulation depth):**
- Stance system: all 4 stances (Sprint/Walk/Careful/Crouch) produce correct tick multipliers
- Tile occupancy: collision detection respects posture layers
- Two-phase verbs: Phase 1 (ObjectType) + Phase 2 (KG filter) produce correct verb sets
- Inventory: Take/Place verbs respect info boundary (carried items private)
Hoshe's involvement: QA review on #449 (ObserverSnapshot v6 — critical bridge change), #422 (two-phase verbs — complex KG integration), #430 (fog shader — large rewrite, performance regression risk).
## Integration Risks
**Risk:** ObserverSnapshot v6 wire format changes break existing client rendering.
**Mitigation:** Server #449 lands early (day 1-2). Stig tests client with new wire format before implementing #438 and #439. If deserialization breaks, revert #449 and coordinate with Dudley on schema fix.
**Risk:** Fog shader rebuild (#430) is a complete rewrite. High risk of regressions (performance, visual artifacts, z-layer conflicts).
**Mitigation:** Stig implements #430 on feature branch, tests in isolation before merging. Performance target: <1ms/frame (monitor with Godot profiler). If performance regresses, scale back noise animation complexity (reduce cycle count or simplify Perlin shader).
**Risk:** Two-phase verb computation (#422) is architecturally complex. Risk of Phase 1/Phase 2 boundary mismatches or KG query bugs.
**Mitigation:** Dudley implements #421 (ObjectType) first to validate Phase 1 logic. Add integration tests for Phase 2 KG filter (verify correct verbs per character archetype). Tyre reviews architecture before merge.
**Risk:** Copy #441 (item specs) might not align with server #424 YAML schema expectations.
**Mitigation:** Dudley defines YAML schema early in sprint (day 1), shares with Paula. Paula authors item specs against known schema. Server #424 uses placeholder data until copy delivers, so no blocking dependency.
**Risk:** Sprint has 18 tickets across 3 teams. High ticket count = coordination overhead.
**Mitigation:** Parallel tracks reduce blocking. Server #449 unblocks client early. Fog shader (#430) and world radial menu (#433) are standalone (no cross-team dependencies). Copy #441 is small (2-3 hours). Most complexity is server-side (#417, #422, #424) where Dudley has focused scope.
## Definition of Done
Sprint 6 is complete when:
1. All 18 tickets marked `done` in database
2. All server code committed to `server` branch and tested
3. All client code committed to `client` branch and tested
4. All copy content committed to `copy` branch and validated
5. PRs created for all three teams
6. Sprint completion proof checklist verified (all 16 observable criteria met)
7. Team Leader reviews in-game playtest: stance toggle, fog shader, interaction verbs, inventory UI all working together without desyncs or visual regressions
## PR Coordination
Three team PRs will be created:
1. **Server team PR**:
- Branch: `server`
- Scope: ObserverSnapshot v6, stance system, tile occupancy, two-phase verb computation, smuggler inventory
- Review focus: Bridge protocol compatibility, simulation correctness, KG integration
2. **Client team PR**:
- Branch: `client`
- Scope: Cursor states, fog shader rebuild, interaction list, world radial menu, inventory UI, stance UI
- Review focus: Rendering performance, visual consistency with Araminta spec, z-layer correctness
3. **Copy team PR**:
- Branch: `copy`
- Scope: Smuggler item specs (3 items)
- Review focus: Content quality, voice consistency, knowledge graph integration
All PRs target `main` branch. Server PR should merge first (provides wire format for client). Client and copy can merge in parallel after server lands.
## Notes for Team Leader
Sprint 6 is the tactile sprint. After 5 sprints of infrastructure, this is the first sprint where the player's hands touch the world:
- **Movement feels deliberate:** Stance toggle creates tactical choices (sprint ahead to position, careful to observe, walk when safe). Speed is not just convenience — it's intention.
- **Fog feels alive:** The shader rebuild (D-059) replaces the current flat fog with breathing, animated, knowledge-graph-driven fog. This is the visual signature of asymmetric information.
- **Interaction feels responsive:** Cursor states (D-056) and vertical lists (D-057) replace generic "E to interact" with character-aware, knowledge-gated verb menus. The world responds to who you are, not just where you are.
- **Inventory feels risky:** Smuggler carries contraband (manifest, token, log). These items are leverage and liability. Detective carries authority, not objects. Character differentiation through what you hold.
Sprint 6 delivers D-027 criterion #4: "observe → notice → follow → discover sequence emerges from systems not scripts." The stance system (observe in Careful), the verb system (notice contradictions), the fog system (follow entities in fog), and the inventory system (discover evidence) compose into emergent detective gameplay.
Quality bar: All systems must compose without breaking each other. Stance + fog + verbs + inventory running simultaneously with no desyncs, no performance regressions, no visual artifacts. Integration is the success metric.
+124
View File
@@ -0,0 +1,124 @@
# Sprint 6: Touch — Server Tasks
**Goal:** Movement stances, fog rebuild, multi-verb interactions, and smuggler inventory
**Branch:** `server`
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #449 | ObserverSnapshot v6 — add player_stance, player_inventory wire fields | — |
| #417 | Stance system — Sprint/Walk/Careful/Crouch with tick-based movement | #444 (done) |
| #418 | MovementProfile component per archetype | #417 |
| #419 | Sprint interaction buffer suppression | #417 |
| #420 | Same-tile occupancy — TilePresence with posture layers | #444 (done) |
| #421 | ObjectType component + verb sets per type | — |
| #422 | Two-phase verb computation — KG-gated Phase 2 observer filter | #421 |
| #424 | Smuggler inventory — CarriedBy component, Take/Place verbs, info boundary | #445 (done) |
| #428 | Sprint anomaly monologue — double-take survival | — |
Use `db/connectors/ticket show <id>` for full details.
## Key Decisions
- `decisions/scope.md` — D-053 (Movement as stance toggle system), D-065 (Smuggler inventory)
- `decisions/architecture.md` — D-054 (Tile-based movement with same-tile occupancy), D-055 (Sprint explicitly suppresses interaction buffer)
- `decisions/perception.md` — D-057 (Entity interaction — vertical list, two-phase verb computation)
## Open Questions Resolved
- **OQ-01: Tile size** — Resolved to 0.5m x 0.5m sim tiles (ticket #444, done). Amended by D-066: dual-scale grid with 1m visual tiles (2x retina factor). All world geometry 2x2 sim tile minimum.
- **OQ-24: Inventory capacity** — Resolved to 3x3 grid = 9 slots universal (ticket #445, done)
## Notes
### #449: ObserverSnapshot v6 — player_stance, player_inventory
- **What exists:** `server/src/bridge/types.rs` defines ObserverSnapshot v5 (current version: 4, see line 30-48)
- **Needs:** Add two new fields to ObserverSnapshot struct:
- `player_stance: MovementStance` (enum: Sprint/Walk/Careful/Crouch)
- `player_inventory: Vec<VisibleItem>` (items carried by player, visible to player only per D-010 info boundary)
- **Integration:** #417 provides MovementStance enum, #424 provides CarriedBy component query
- **Critical path:** This ticket unblocks client #439 (stance UI) and #438 (inventory UI)
### #417: Stance system — tick-based movement
- **What exists:** `server/src/simulation/movement.rs` has basic movement at 1 tile/2 ticks (Walk default)
- **Needs:** Add MovementStance component (enum: Sprint/Walk/Careful/Crouch/Prone), implement stance ladder toggling via PlayerAction::ToggleStance, apply tick multipliers per stance (Sprint=1/1, Walk=1/2, Careful=1/3, Crouch=TBD), wire monologue rate modifiers (Sprint=40%, Walk=100%, Careful=150%)
- **D-053 spec:** Sprint suppresses monologue to 40%, Careful enhances it to 150%. Perception coupling is multiplicative. No vision cone changes (cognitive, not sensory).
- **Gotcha:** Prone is toggle-out only in normal play (must stand up explicitly). Future combat allows "hit the deck" quick-entry. v0.1 scope: Sprint/Walk/Careful/Crouch only.
- **Integration:** Blocks #418 (MovementProfile), #419 (sprint suppression), #426 (ListeningFocus deferred), #439 (client stance UI)
### #418: MovementProfile component per archetype
- **Needs:** Add MovementProfile component storing default stance per archetype (smuggler=Walk, detective=Walk). Apply on spawn.
- **Rationale:** Future archetypes may have different defaults (e.g., maintenance worker defaults to Careful). Smuggler and detective both start Walk for v0.1.
- **Integration:** Blocked by #417 (stance system must exist first)
### #419: Sprint interaction buffer suppression
- **What exists:** `server/src/simulation/interaction.rs` computes nearby_interactions for ObserverSnapshot
- **Needs:** When player stance == Sprint, explicitly clear interaction buffer (nearby_interactions = empty vec). Anomaly monologue survives sprint (D-055: sprint suppresses interpretation, not sensory data).
- **D-055 spec:** Interaction verbs not computed during sprint. Sprint double-take: if character passes anomaly while sprinting, delayed monologue fires retroactively ("Wait — was that Kael? At this hour?").
- **Integration:** Blocked by #417 (stance system), works with #428 (anomaly monologue)
### #420: Same-tile occupancy — TilePresence layers
- **What exists:** Movement system uses discrete tile positions (Position component)
- **Needs:** Add TilePresence component (enum: Standing/Prone/Seated/Fixture), allow multiple entities per tile if different posture layers, update collision detection to check layer conflicts
- **D-054 spec:** Tile-based movement enables determinism (D-010 principle 4). Occupancy adds positioning depth (doorway blocking, eavesdrop positioning, sitting at furniture) within tile constraints. ~150 lines.
- **Integration:** Crouch stance (#417) uses Prone/Seated layer. Enables future eavesdrop mechanics (#426, deferred).
### #421: ObjectType component + verb sets per type
- **Needs:** Add ObjectType component (enum: Readable, Container, Terminal, Door, Pickup, Furniture), define verb sets per type (e.g., Readable → {Read, Observe}, Container → {Open, Search, Observe}), spawn objects with ObjectType in map loader
- **D-057 context:** ObjectType drives Phase 1 verb computation (max possible verb set, no KG). Phase 2 filters by observer's KG.
- **Integration:** Blocks #422 (two-phase verb computation)
### #422: Two-phase verb computation — KG observer filter
- **What exists:** `server/src/simulation/interaction.rs` computes verbs, `server/src/knowledge/` has KG query infrastructure
- **Needs:** Refactor verb computation into two phases:
- **Phase 1 (simulation, no KG):** Query ObjectType component → return max verb set for that type
- **Phase 2 (observer, reads KG):** Filter Phase 1 verbs by player's knowledge (e.g., Confront requires KnowsDetails+ per D-041), apply POI priority flips, add contradiction markers, character-archetype verb variation (smuggler sees "Move/Stash", detective sees "Scan/Flag" for same crate)
- **D-057 spec:** Vertical list handles 2-4 options max. New verbs unlocked by KG changes highlighted with glow (client-side). Character differentiation via Phase 2 observer filter, not separate verb systems.
- **Integration:** Blocked by #421 (ObjectType). Enables rich interactions without hardcoding character logic into simulation.
### #424: Smuggler inventory — CarriedBy, Take/Place verbs
- **What exists:** Entities are world entities in ECS, KG tracks knowledge (not items)
- **Needs:** Add CarriedBy(StableId) component referencing carrier entity, implement Take/Place verbs (add/remove CarriedBy component), respect info boundary (carried items private, not visible to other entities unless revealed via search/scan/confrontation), query system: all items WHERE CarriedBy == player
- **D-065 spec:** Knowledge is primary inventory (you SAW the manifest). Physical inventory is secondary (3 specific items for smuggler in v0.1: manifest copy, access token, comm log). Smuggler 9 slots, detective 2 slots (D-065 updated after OQ-24 resolution: 3x3 grid = 9 slots universal).
- **Gotcha:** Contraband detection (#425, deferred) will scan carried items via KG + CarriedBy query. Items behind info boundary until revealed.
- **Integration:** Blocks #425 (contraband, deferred), #438 (client inventory UI)
### #428: Sprint anomaly monologue — double-take
- **Needs:** When player in Sprint stance passes an anomaly (e.g., THE FRIEND in wrong location/wrong time), suppress immediate monologue but fire delayed retroactive monologue after ~1-2s: "Wait — was that Kael? At this hour?"
- **D-055 context:** Sprint suppresses interpretation (monologue at 40%), not sensory data. Anomaly survival creates the "sprint double-take" feel — character's brain catches up after the fact.
- **Integration:** Works with #419 (sprint suppression), uses existing monologue pipeline (#414, done in Sprint 5)
## Dependency Chain
```
Critical path:
#449 (ObserverSnapshot v6) → standalone, unblocks client #438, #439
#417 (Stance system)
├─→ #418 (MovementProfile)
├─→ #419 (Sprint suppression)
└─→ #420 (TilePresence, parallel but both use tick system)
#421 (ObjectType) → #422 (Two-phase verb computation)
#424 (Smuggler inventory) → standalone, unblocks client #438
#428 (Sprint anomaly monologue) → standalone, parallel work
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
```bash
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "feat(simulation): movement stances, tile occupancy, two-phase verbs, smuggler inventory" \
--description "Sprint 6 server deliverables per D-053, D-054, D-055, D-057, D-065" \
--base main \
--head server
```
+3 -2
View File
@@ -20,11 +20,12 @@ docs/workshops/
| [Content Architecture](content-architecture/) | 17 | Complete |
| [v0.1 Gap Analysis](v01-gap-analysis/) | 18 | Complete |
| [Content Gap Analysis v0.1](content-gap-analysis_v0_1/) | — | Complete |
| [Knowledge Graph & Information Boundaries](knowledge-graph-information-boundaries/) | — | Brief ready |
| [Observer Snapshot Pipeline](observer-snapshot-pipeline/) | — | Brief ready (blocked by Knowledge Graph) |
| [Knowledge Graph & Information Boundaries](knowledge-graph-information-boundaries/) | — | Complete (2 rounds + synthesis, D-041 confirmed, 8 tickets created) |
| [Observer Snapshot Pipeline](observer-snapshot-pipeline/) | — | Brief ready |
| [NPC AI State Machines](npc-ai-state-machines/) | — | Brief ready |
| [Save/Load Architecture](save-load-architecture/) | — | Brief ready |
| [Map Authoring Pipeline](map-authoring-pipeline/) | — | Brief ready |
| [Wiki Review & Content Standards](wiki-review/) | — | Complete (4 rounds + lead interview) |
| [v0.1 Content Scoping](v01-content-scoping/) | — | Complete (2 rounds + closing, 38 tickets created) |
| [Art Direction & Mood Board](art-direction-mood-board/) | — | Complete (3 rounds + closing + technical session, D-019 amended, D-043-D-052 confirmed) |
| [Control & Interaction Scheme](control-interaction/) | — | Brief ready |
@@ -54,8 +54,8 @@ Each participant's one-sentence answer to "What is this game's visual identity?"
- Entity facing direction becomes readable (see someone's front vs. back)
**What doesn't change:**
- Tile grid stays orthogonal (64x64, square)
- Vision cone computed on 2D grid (Rust shadowcasting, D-035)
- Tile grid stays orthogonal (64x64px visual tiles = 2x2 sim tiles per D-066, square)
- Vision cone computed on 2D sim grid (Rust shadowcasting, D-035)
- Light2D pipeline unchanged
- Fog of perception unchanged
- Environmental neutrality unchanged
@@ -67,11 +67,12 @@ Each participant's one-sentence answer to "What is this game's visual identity?"
### 1.3 World Composition — Tile-Based
**64x64 tile grid, 1x1 placed objects, base-builder compatible.**
**Dual-scale grid: 0.5m sim tiles, 1m visual tiles (2x retina factor, [D-066](../../decisions/architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)). Visual tiles are 64x64px.**
- Structural tiles (walls, floors, doors): 64x64, muted, minimal outlines, zone palette + era differentiation
- Object tiles (furniture, equipment, containers): 64x64 per 1x1, medium outline (1px), Rimworld object detail as target
- Larger objects composed of 1x1 sub-tiles (e.g., 3x1 bar counter, 2x1 desk)
- Structural tiles (walls, floors, doors): 64x64px per visual tile (= 2x2 sim tiles), muted, minimal outlines, zone palette + era differentiation
- Object tiles (furniture, equipment, containers): 64x64px per visual tile, medium outline (1px), Rimworld object detail as target. All world geometry is 2x2 sim tile minimum.
- Larger objects composed of multiple visual tiles (e.g., 3x1 bar counter, 2x1 desk — in visual tile units)
- Entities occupy 1x1 sim tiles (0.5m) for fine positioning within the 1m visual grid
- Three construction eras as tile palettes (see §1.8)
- Stations are literally built this way — prefab modular construction = tile grid IS the construction grid (Miri)
@@ -95,9 +96,9 @@ Each participant's one-sentence answer to "What is this game's visual identity?"
### 1.5 Entity System
**24x32 pixel footprint within 64x64 tiles.**
**24x32 pixel footprint within 64x64px visual tiles. Entities occupy 1x1 sim tiles (0.5m) but render across a 2x2 sim tile sprite footprint per [D-066](../../decisions/architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor).**
- Entity smaller than tile = clear figure-ground relationship
- Entity smaller than visual tile = clear figure-ground relationship
- D-033 color as primary information signal
- Silhouette as primary identity signal (I-01: "Silhouette IS identity because color is spoken for" — Ozzie)
- One identifying silhouette feature per named NPC (Kael's vest, Lera's apron, Sera's Commission uniform)
@@ -490,7 +491,7 @@ Cyberpunk neon / Blade Runner rain-noir, Star Trek antiseptic, Star Wars romanti
The following workshop outputs are ready for formal recording in `decisions/` domain files. All have unanimous consensus. Proposed numbering starts at D-042 (D-041 is the current highest).
### Candidate D-042: Art Direction — Visual Style
- **Decision:** Clean 2D with bold silhouettes, tile-based world composition (64x64 grid), lighting-driven atmosphere. "Functional warmth." Not pixel art, not painted, not 3D. Godot 4 Light2D pipeline. Sprites are shape templates that the lighting system completes — no baked shadows, no baked lighting, no baked mood.
- **Decision:** Clean 2D with bold silhouettes, tile-based world composition (64x64px visual tiles on dual-scale grid per D-066), lighting-driven atmosphere. "Functional warmth." Not pixel art, not painted, not 3D. Godot 4 Light2D pipeline. Sprites are shape templates that the lighting system completes — no baked shadows, no baked lighting, no baked mood.
- **Rationale:** Four independent agents converged on the same style. Production constraints (Nano Banana), engine capabilities (Godot 4 Light2D), design requirements (D-033 readability), and thematic goals (restraint as confidence) all point to the same solution.
- **Domain:** perception.md (cross-ref: architecture.md for Godot pipeline)
- **Raised by:** Araminta (lead), endorsed unanimously
@@ -608,7 +609,7 @@ The following workshop outputs are ready for formal recording in `decisions/` do
|---|---|---|
| Source render | 1024x1024 | Archive master. Maximum detail from SubViewport. |
| Working | 256x256 | Outlines applied here (4-8px → 1-2px at runtime). Asset QA resolution. |
| Runtime | 64x64 | Final in-game sprite. Must read through silhouette and color, not texture detail. |
| Runtime | 64x64 | Final in-game sprite (= 1 visual tile per D-066). Must read through silhouette and color, not texture detail. |
- **Interpolation:** Bilinear for both downscale passes
- **Outline application:** At 256 working resolution. 4px outline at 256 = 1px at 64 runtime. Color: dark blue-grey `#333340`.
@@ -2,153 +2,201 @@
## How Does the Player Touch the World?
**Project:** The Settled Reach (D-021)
**Date:** 2026-02-12
**Date:** 2026-02-13
**Called by:** Jeroen
**Prerequisite:** Wiki Review workshop established WASD direct control with context-sensitive interaction as unanimous direction.
**Participants:** TBD — likely GESTALT, OZZIE, TYRE, STIG, DUDLEY, PAULA, MELLANIE, ARAMINTA
**Participants:** Gestalt, Ozzie, Tyre, Stig, Dudley, Paula, Araminta, Nigel
---
## Context
The Wiki Review workshop (2026-02-12) produced unanimous consensus: the player controls their character directly via WASD movement with context-sensitive interaction prompts. The game is not Sims/Rimworld (drafted characters), not point-and-click. The player IS the character. Their body is their attention mechanism.
The Wiki Review workshop produced unanimous consensus: the player controls their character directly via WASD movement with context-sensitive interaction prompts. The player IS the character. Their body is their attention mechanism.
This workshop designs the specifics. The interaction model determines everything downstream: camera, UI, dialogue triggers, NPC approach, environmental interaction, inventory, information access, and the perception modes that are core to the game's asymmetric information design.
This workshop designs the specifics of how the player controls the character, interacts with entities and objects, and accesses information through the UI.
**Design constraints from existing decisions:**
**Already decided (do not re-litigate):**
- D-005: Single-character perspective
- D-013: Diegetic insert (neural implant map/minimap)
- D-016: Internal monologue as perception bridge
- D-017: Perception modes as character build
- D-018: Three-range sound model
- D-020: Godot 4 client + Rust/bevy_ecs server
- Wiki Review: WASD direct control, context-sensitive interaction, body-as-attention-mechanism
- D-013: Diegetic insert (neural implant) as map/information interface
- D-015: Camera locked to character, no panning. Rotation deferred to post-v0.1.
- D-016: Internal monologue as core perception/atmosphere system
- D-017: Perception modes as character build system
- D-018: Three-range sound model (close/medium/long)
- D-019: Top-down camera at ~15-20° shallow tilt ("the angle"), orthographic, perspective in sprite art
- D-028: Dialogue architecture (4-layer: access, history, trust, unprompted)
- D-031: Time system (10 ticks/game-minute, 4 day phases, pause available)
- D-033: Entity color = relationship to player (palette defined)
- D-041: Knowledge graph (confidence hierarchy gates dialogue and monologue)
- D-043: Visual style — "functional warmth," clean 2D, bold silhouettes, Light2D-driven atmosphere
- D-044: Visual hierarchy — entity (2px) > object (1px) > structure (minimal). Entity always wins.
- D-045: Environmental neutrality — strict zero visual shift from narrative state
- D-046: Lighting — Darkwood cone + BR2049 color temperature + Hopper composition
- D-047: Two-tier animation — clear (public routine) vs ambiguous (private intention)
- D-048: Neural insert overlay — geometric data + bloom shader, not affected by fog
- D-049: Z-level rendering stack — 8 layers, fog on layer 5, insert on layer 6
---
## Layer 1: Movement & Camera
## Lead Seed: Current Thinking on Controls
**How does the character move through space?**
The following is Jeroen's initial direction. Workshop participants should use this as a starting point — challenge, refine, or extend, but this is the baseline.
- **Movement**: WASD (confirmed). But: 8-directional grid? Free movement? Physics-based? Sprint/walk toggle? Sneak mode?
- **Camera**: Top-down (confirmed by "top-down immersive sim"). But: fixed angle? Rotatable? Zoom level? How close before you can read a sign? How far before NPCs are silhouettes?
- **Collision**: Tile-based? Free-form? Can you squeeze between tables? Block doorways?
- **Speed**: Does speed affect perception? Walking slowly = more monologue triggers? Running = less observation?
### Movement
- **WASD movement**, character stays center-screen, map scrolls.
- Movement direction snaps to the **nearest cardinal 8-axis direction** from the input.
- **Mouse pointer controls facing direction.** The character looks where the mouse points. This decouples movement from facing — you can strafe sideways while looking at a person of interest.
**Questions:**
1. **Gestalt**: Does movement speed affect information gathering? Should walking vs. running change what you notice?
2. **Tyre**: Godot 4 tile-based or free movement? What's the technical recommendation?
3. **Ozzie**: What camera distance feels right for "I am this person" vs. "I can see the room"?
4. **Araminta**: What visual information density works at the proposed camera distance?
### Camera
- **Fixed top-down angle** at ~15-20° from vertical ("the angle," D-019 amendment). Orthographic camera with perspective faked in sprite art. Rimworld's approach: sprites drawn as if viewed from a shallow tilt, south-facing front faces visible. 3D render pipeline Camera3D at -72.5° produces this. Vision cone math remains pure 2D.
- **Scroll wheel zooms** in/out, but vision cone and fog still apply — zooming out doesn't grant omniscience. You see more floor, but the fog boundaries don't change.
- Character remains center-screen at all times (D-015).
### Mouse & Interaction
- **Mouse cursor is context-sensitive.** It changes appearance when hovering over interactable entities (NPCs, objects, terminals, doors).
- **Left click** performs the default action on an interactable. When there is exactly one interaction option, the cursor shows the **verb as a tooltip** (e.g., "Talk", "Read", "Open"). When there are multiple options, the tooltip shows **"Interact..."** and left click opens the menu.
- **Radial or dropdown menu** for multi-option interactions. Never more than one click away from any action.
- **Mouse doubles as targeting reticle** when a weapon is selected — the cursor becomes the aim point.
- **Right click** opens a **world interaction menu** — a general-purpose context menu for non-entity actions: observe room, call taxi, open phone (the neural implant interface). This is the entry point to the insert UI, comms, maps, journal, and any lattice-connected tools the character has.
### Fog & Visibility Layers
The map is visible at all times, but **layers of fog encode information quality:**
1. **Clear (forward vision cone):** Full visibility, no fog. Blocked by walls/obstacles as normal (D-011 shadowcasting).
2. **Light fog (peripheral vision):** Reduced clarity. You can see shapes and movement but detail is diminished.
3. **Sound pings in fog:** When you hear something in the fog (D-018 medium range), a **visual ping** appears at the fog edge with binaural audio reinforcement. Direction and distance are approximate.
4. **Recognized movement in fog:** People you have **previously seen** (knowledge graph entry exists) and can **currently hear moving** appear as visible figures in the gray fog. Their identity is known because you recognize them — the character icon overlays the gray figure to communicate *how* you know they're there.
5. **Unrecognized presence:** When you hear someone you haven't identified, a **generic gray blob** appears at an approximate (fuzzy, non-exact) position. Secondary senses tell you something is there, but not who.
6. **Augmented senses:** If your character has IR or sensor implants (D-017 perception modes), additional overlays appear in the fog:
- **IR/thermal:** A blob in heat-signature colors.
- **Sensor sweep:** A stylized digital effect (matrix-style falling characters or similar) indicating a detected presence.
- When you **know who that entity is**, their character icon overlays the sensor blob — showing both the detection method and the identification.
**Lead note:** "I want a clear distinction between active senses and obscured areas, but I don't want it to look boring." The fog should feel alive — not a flat gray overlay but something with depth and atmosphere. Pings, movement, sensor ghosts should make the fog an active information surface, not a dead zone.
### Map Awareness (Fog of War for Unexplored Areas)
What you see in areas you haven't visited depends on your **implant loadout:**
- **With a maps app (implant):** Building outlines and structural layout are visible in the fog — you can see the shape of corridors, rooms, exits. But **no movable entities** — no characters, vehicles, destructible objects, or anything generated/modified by the story engine. The map data is static infrastructure only.
- **Without an implant (or without maps app):** Total fog. Zero information about unexplored areas.
- **Physical map (purchased at a shop):** Same as maps app but non-dynamic — shows the map as it was when printed. Doesn't update with story-engine changes.
This creates a meaningful difference between implant loadouts at the start of the game without gating core gameplay.
---
## Layer 2: Context-Sensitive Interaction
## Layer 1: Movement & Interaction Details
**What happens when you approach something?**
The lead's seed covers the broad strokes. The workshop should resolve these remaining specifics:
The Wiki Review poll referenced Disco Elysium interaction and Hotline Miami movement. The key design question: how do interaction prompts appear, and how does the player select from options?
### Movement
- **Sprint/walk/sneak toggle?** Does speed affect perception? Walking slowly = more monologue triggers, more observation? Running = reduced vision cone, less detail noticed? Sneaking = slower but quieter (D-018 sound model — you generate less noise)?
- **Collision model:** Tile-based movement (snap to grid) or free movement with tile-based collision? Can you squeeze between furniture? Block doorways?
- **Proximity trigger**: How close before options appear? Line-of-sight required?
- **Prompt style**: Single action prompt (changes based on context)? Radial menu? List? Paula suggested single-action prompts that change based on proximity + relationship + knowledge.
- **NPC interaction**: Walk up to NPC → what appears? "Talk"? Multiple options (talk, observe, confront)? Does relationship state change the options?
- **Object interaction**: Examine, use, take? How do you read a manifest on a desk vs. pick up a crate?
- **Environmental interaction**: Doors, terminals, cargo containers, signs — each different?
- **Eavesdropping**: Can you hear NPCs talking without direct interaction? How does proximity affect this?
### Interaction
- **Proximity threshold:** How close before the cursor changes and interaction becomes available? Line-of-sight required, or can you interact through a doorway?
- **Eavesdropping:** Is overhearing NPC conversations purely passive (D-018 medium range triggers monologue), or can you actively position yourself to listen? Is there a "lean in" or "listen" action?
- **NPC interaction options:** What's the option set? Does it vary by relationship state and knowledge level (D-041 confidence tiers)?
- Unknown NPC: "Observe" / "Talk"
- Known NPC (KnowsOf+): "Talk" / "Observe" / additional context-sensitive options
- KnowsDetails+: "Confront" appears as an option
- **Object interaction set:** Examine, use, take — or is it purely contextual? How does reading a manifest differ from picking up a crate?
**Questions:**
1. **Gestalt**: How many interaction options per NPC at once? Does complexity vary by relationship state?
2. **Paula**: How does dialogue initiation work? Player approaches NPC → what's the UX to the first line of dialogue?
3. **Stig**: What UI patterns support context-sensitive prompts without cluttering the screen?
4. **Dudley**: How does the server determine what interaction options are available based on entity state?
**Questions for participants:**
1. **Gestalt**: Does movement speed mechanically affect information gathering? Is this fun or just punishing?
2. **Tyre**: Tile-based vs free movement in Godot 4 — technical recommendation for v0.1?
3. **Stig**: What UI pattern for the context-sensitive cursor + tooltip/menu? Reference implementations?
4. **Dudley**: How does the server compute available interaction options per entity per tick?
5. **Ozzie**: Does the cursor-as-reticle / cursor-as-interaction-prompt duality feel natural or jarring?
6. **Araminta**: What does the context-sensitive cursor look like across states (default, interactable hover, weapon aim)?
---
## Layer 3: Information & Perception
## Layer 2: Fog & Perception UX
**How does the player ACCESS information vs. RECEIVE information?**
D-016, D-017, D-018 define WHAT information reaches the player. This layer is about HOW it's presented.
The game has two information channels:
1. **Active**: Player chooses to look, examine, query the lattice, read something
2. **Passive**: Monologue fires based on observation triggers, overheard conversations, environmental tells
### Open Questions
- **Fog rendering:** The lead wants fog that feels alive, not flat. What visual treatment makes the obscured areas atmospheric without obscuring gameplay information? Particle effects? Animated noise? Depth layers?
- **Sound ping visualization:** What does a medium-range sound ping look like at the fog edge? Ripple? Directional arrow? Glow pulse? How does it interact with binaural audio?
- **Sensor overlay aesthetics:** IR blobs, digital sensor sweeps, recognized-entity icon overlays — how do these layer without becoming visual noise? How many simultaneous perception modes can be active before the screen becomes unreadable?
- **Identity overlay on fog entities:** When you recognize a heard/sensed entity, their icon appears over the blob. What does this transition look like? Instant? Fade-in? Does it feel like a moment of recognition?
- **Lattice queries**: How do you look up an NPC via the neural lattice? A UI panel? A diegetic device? Walk up and "scan"?
- **Monologue delivery**: Text overlay? Speech bubble? Dedicated UI area? How does it not compete with dialogue?
- **Tell observation**: Player walks past NPC with a behavioral tell — how is it surfaced? Monologue? Visual indicator? Both?
- **Knowledge journal**: How do you review what you know? Diegetic (the implant's records)? Pause menu?
**Questions:**
1. **Gestalt**: Where's the line between "the game tells you" (monologue) and "you have to look" (active observation)?
2. **Ozzie**: When does information delivery feel immersive vs. intrusive?
3. **Tyre**: How does the knowledge graph surface in the UI without becoming a database viewer?
4. **Mellanie**: How much monologue can appear before the player stops reading?
**Questions for participants:**
1. **Araminta**: Visual treatment for the fog layers (clear → light fog → deep fog → unexplored). How do we make fog atmospheric, not boring?
2. **Gestalt**: How many simultaneous perception overlays before it stops being a build system and starts being sensory overload?
3. **Ozzie**: What makes the "I hear someone I know moving in the fog" moment feel cool rather than routine?
4. **Tyre**: Performance implications of layered fog with animated overlays, sensor effects, and entity ghosts in Godot 4?
---
## Layer 4: Dialogue & Conversation
## Layer 3: Information Access & the Implant UI
**How do conversations work mechanically?**
D-013 defines the insert as diegetic. The right-click world menu is the entry point. What's behind it?
- **Initiation**: Player walks up, presses interact. Then what? Dialogue box? In-world text? Camera zoom?
- **Response selection**: Menu of options? Single-branch with trust-gated variations? How does the 4-layer dialogue system (access, history, trust, unprompted) translate to player-facing UX?
- **Conversation persistence**: Can you walk away mid-conversation? Resume later? Does the NPC react?
- **Dual-lens integration**: Same NPC, different options per character. How does the UI show what's available vs. locked?
- **Confrontation**: How does "I know you're involved" differ from casual conversation in the UX?
### Open Questions
- **Right-click menu scope:** What actions live in the world menu? Observe room, call taxi, open phone — what else? Is this the pause menu equivalent?
- **Phone/implant UI:** What does "open phone" look like? Full-screen overlay? Corner panel? Does the game pause or continue while you're in the implant UI?
- **Knowledge journal:** How do you review what you know? Diegetic (implant records)? Is it organized by person, by topic, by timeline?
- **Maps app UX:** How does the implant map interact with the game world? Overlay on the main view? Separate panel? Minimap always visible or on-demand?
- **Active vs passive information:** The game pushes information via monologue (passive). The player pulls information via the implant (active). Where's the line? Can you "scan" an NPC for lattice data, or do you have to talk to them?
**Questions:**
1. **Paula**: What does the confrontation UX look like vs. casual conversation? Should the player feel the tension mechanically?
2. **Mellanie**: How does monologue fire DURING dialogue? (Character reacts to what the NPC said)
3. **Stig**: Dialogue box design — full screen? Corner overlay? In-world?
4. **Gestalt**: How do trust-gated options appear (or not appear) without revealing the gating system?
**Questions for participants:**
1. **Gestalt**: What information should the player actively seek vs. what should the game push? Where's the sweet spot for detective work?
2. **Stig**: Right-click world menu implementation — radial? List? Nested? What fits the diegetic framing?
3. **Tyre**: Does the game pause when the implant UI is open? What are the multiplayer implications (D-009)?
4. **Ozzie**: When does information access feel like detective work vs. menu navigation?
---
## Layer 5: Environmental Interaction & Inventory
## Layer 4: Dialogue Initiation & Confrontation UX
**What can you touch, carry, and use?**
D-028 defines the dialogue architecture (4 layers: access, history, trust, unprompted). This layer is about the player-facing UX — what the player sees and does, not the underlying system.
- **Inventory**: Does the character have inventory? Pockets? A bag? Or is everything contextual (use the terminal, read the manifest, you don't carry things)?
- **Evidence**: How does the detective collect evidence? Take a photo? Log it in the lattice? Pick it up?
- **Contraband**: How does the smuggler interact with cargo? Physical handling? Abstract management?
- **Keys/access**: Physical keys? Access codes? Lattice authorization levels? How does restricted access feel in the UX?
### Open Questions
- **Initiation flow:** Player walks up to NPC, cursor shows "Talk", clicks. Then what? Dialogue box appears? Where? How does the transition feel?
- **Response selection:** Does the player see a list of topics/questions? Single-branch with the system selecting which lines are available? Or explicit choice from visible options?
- **Gated options visibility:** Do locked dialogue options show as grayed-out (player knows they're missing something) or are they invisible (player doesn't know what they don't know)? The latter fits the information asymmetry design better.
- **Confrontation feel:** "I know you were in corridor B-7" should feel different from "How's your shift going?" How does the UI communicate tension without a separate confrontation system?
- **Walk-away:** Can you leave mid-conversation? What's the NPC reaction? Can you resume?
- **Mid-dialogue monologue:** Character reacts internally to what the NPC says (D-016). How does monologue text appear during a dialogue exchange without competing?
**Questions:**
1. **Gestalt**: How physical is the interaction model? Carry objects? Or is everything knowledge-based (you SAW the manifest, you don't HAVE the manifest)?
2. **Tyre**: What's feasible for v0.1 inventory? Full system or knowledge-only?
3. **Nigel**: Does inventory variation matter for replayability, or is knowledge-based sufficient?
**Questions for participants:**
1. **Paula**: How should confrontation feel different from casual conversation in the UX?
2. **Stig**: Dialogue box design — where on screen, how much space, how does it coexist with the game world?
3. **Gestalt**: Should locked options be invisible or visibly locked? What serves the "asymmetric information" feel?
4. **Ozzie**: What makes dialogue initiation feel natural — like walking up to someone — rather than "pressing the talk button"?
---
## Layer 6: Time & Routine
## Layer 5: Inventory & Physical Interaction
**How does time pass and how does the player relate to schedules?**
### Open Questions
- **Inventory existence:** Does the character carry items, or is everything knowledge-based? "You SAW the manifest" vs "you HAVE the manifest" — which model fits the game?
- **Evidence collection:** Detective logs evidence in the lattice? Takes photos? Or just observes and the knowledge graph records it?
- **Contraband handling:** Smuggler interacts with cargo physically (move crates, hide packages) or abstractly (manage shipments via terminal)?
- **Access systems:** Physical keys, access codes, lattice authorization — how does restricted access feel? Does the smuggler pick locks while the detective flashes credentials?
- **Time control**: Real-time? Acceleratable? Pause-to-think? Day/night cycle?
- **Routine awareness**: Can you see NPC schedules? Predict where someone will be? Or do you learn through observation?
- **Waiting**: Can you wait/skip time? Stand outside someone's shift and wait for them?
- **Urgency**: Is there time pressure? Can you miss events? Or is the world patient?
**Questions:**
1. **Gestalt**: Does time pressure create interesting decisions or just stress?
2. **Ozzie**: What pacing feels right for "quotidian with undertow"?
3. **Dudley**: How does the tick-based simulation map to player-perceived time?
**Questions for participants:**
1. **Gestalt**: How physical should the interaction model be? Does carrying items create interesting decisions?
2. **Tyre**: What's feasible for v0.1? Full inventory or knowledge-only with physical interaction deferred?
3. **Paula**: How does evidence collection differ by character? Does the smuggler's version of "evidence" (leverage, receipts, debts) feel different from the detective's?
---
## Workshop Format
**2-3 rounds.**
**2 rounds.**
**Round 1**: Layer-by-layer proposals. Each participant addresses their domain questions with concrete UX descriptions.
**Round 1**: Each participant addresses their domain questions with concrete UX proposals. React to the lead's seed — validate, challenge, or extend. Describe interactions as "the player does X, they see Y, it feels like Z."
**Round 2**: Integration — how do the layers interact? Movement affects perception, perception affects dialogue options, dialogue affects knowledge, knowledge affects interaction prompts.
**Round 3 (if needed)**: V0.1 minimum viable interaction model — what ships first?
**Round 2**: Integration pass — how do the layers interact? Movement affects perception, perception feeds interaction options, interaction feeds knowledge, knowledge changes available interactions. Identify the v0.1 minimum viable interaction model.
## Expected Outputs
- **Interaction model spec**: Complete description of how the player touches the world
- **UI wireframe direction**: Enough for Stig/Araminta to begin prototyping
- **Perception UX spec**: How information flows to the player through the interaction model
- **V0.1 scope**: Minimum viable interaction for the vertical slice
- **Tickets**: New implementation tickets for the interaction model
- **Control scheme spec**: Complete mapping of inputs to actions (keyboard, mouse, gamepad if applicable)
- **Interaction model spec**: How context-sensitive prompts work, what options appear when
- **Fog/perception UX spec**: Visual treatment for fog layers, sensor overlays, entity recognition in fog
- **Implant UI direction**: What lives behind the right-click menu, how it's organized
- **Dialogue UX spec**: Initiation flow, response selection, confrontation feel
- **V0.1 scope**: Minimum viable interaction set for the vertical slice
- **Tickets**: Implementation tickets for the interaction/UI systems
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,395 @@
# Workshop Outcomes: Control & Interaction Scheme
**Workshop:** Control & Interaction Scheme — "How Does the Player Touch the World?"
**Date:** 2026-02-13
**Rounds:** 2 + final lead calls
**Participants:** Gestalt, Ozzie, Tyre, Stig, Dudley, Paula, Araminta, Nigel
**Documenter:** Qatux
**Full notes:** `docs/workshops/control-interaction/workshop-notes.md`
---
## Decisions Produced
This workshop produced 13 decisions ready for formalization. Suggested D-NNN IDs and domain file assignments below.
### D-053: Movement as stance toggle system (scope.md)
- **Decision:** Movement uses a stance toggle ladder: Sprint / Walk / Careful / Crouch / (Prone future). Toggle-based, not hold-to-activate. Each archetype has a default stance via MovementProfile component. All characters have access to all stances. Prone: toggle out only in normal play; future combat allows "hit the deck" quick-entry.
- **Rationale:** Stances compose with perception (sprint = tunnel vision, careful = heightened awareness) and with the tile-based eavesdropping system. Toggle avoids modifier-key fatigue.
- **Raised by:** Lead (final call), building on Gestalt (triad), Dudley (MovementProfile), Nigel (character-defining speed)
- **Server values (Dudley):** Walk = 1 tile/2 ticks. Sprint = 1 tile/1 tick. Careful = 1 tile/3 ticks. Crouch TBD.
- **Perception coupling (Gestalt + Ozzie, endorsed by lead):** Sprint suppresses interpretation (monologue at 40%), not data (overlays still render). Careful enhances monologue (150%) and grants "tell notice bonus." Composable via multiplicative formula: `movement_modifier * perception_load_modifier`.
- **Dissent:** None after lead call. Nigel's character-differentiation concern addressed by MovementProfile (different defaults per archetype).
### D-054: Tile-based movement with same-tile occupancy (architecture.md)
- **Decision:** All movement is tile-based (server-authoritative, discrete positions). Client-side Tween interpolation (100-150ms) hides the grid. Same-tile occupancy via TilePresence component (Standing/Prone/Seated/Fixture layers) allows multiple entities on one tile in different postures. Mouse facing is client-side float; server receives facing octant only.
- **Rationale:** Determinism (D-010). Tile-based enables shadowcasting, pathfinding, and trivial collision. Occupancy system addresses Nigel's emergent positioning needs (doorway blocking, eavesdrop positioning, furniture interaction) within tile-based constraints. ~150 lines server-side.
- **Raised by:** Tyre (tile-based, non-negotiable), Dudley (tiles-per-tick), Nigel (converted: "tiles are BETTER for replayability — discrete positions = finite meaningful choices")
- **Dissent:** None. Nigel initially proposed free movement (Round 1) but converted in Round 2 after seeing how tile-based spatial puzzles create replayability.
### D-055: Sprint explicitly suppresses interaction buffer (architecture.md)
- **Decision:** When in Sprint stance, the server explicitly clears the interaction buffer. No interaction verbs are computed or sent to the client during sprint. Anomaly monologue survives sprint (Ozzie's "sprint double-take").
- **Rationale:** Mouse gymnastics to click during sprint = bad UX. Explicit suppression is cleaner and deterministic. "Sprint double-take" preserves the feel that the character is still aware even when the player can't interact.
- **Raised by:** Dudley (explicit suppression), Ozzie (anomaly survival)
- **Dissent:** Gestalt argued physics handles it naturally (player passes through interaction radius too fast to click). Lead ruled explicit suppression for clarity.
### D-056: Cursor states — insert-styled geometric (perception.md)
- **Decision:** Four cursor states using the neural insert's geometric visual language (Araminta's spec):
- Default: four thin inward-pointing ticks, bloom, white-blue #c8d0e0
- Entity hover: ticks expand outward (150ms), corner brackets frame entity, shifts to D-033 relationship color, verb tooltip in insert styling
- Object hover: ticks rotate 45 degrees to X-shape, muted grey #8b8ba0 (amber #e8c547 if flagged)
- Weapon aim: hard transition, ticks extend, center gap widens, lines thicken 1px to 2px, warm white #f0e8d8, NO bloom. Entity in sights tints to D-033 color.
- All transitions 150ms linear, z-layer 7, never changes by zone or narrative state (D-045).
- Cursor changes on LOS, not just proximity (Stig). Click range: ~2 sim tiles (= 1m per D-066). Weapon-selected mode suppresses interaction prompts unless Shift held (Ozzie: "combat intent trumps social intent").
- **Rationale:** Diegetic — cursor is the insert's own interface element. Consistent with D-048 (insert overlay aesthetic). Entity in weapon sights tinting to D-033 creates moral friction (aiming at a friend should feel wrong).
- **Raised by:** Araminta (visual spec), Stig (UX rules), Ozzie (weapon suppression)
- **Dissent:** None.
### D-057: Entity interaction — vertical list, insert-styled (perception.md)
- **Decision:** Entity interactions use a compact vertical list (not radial). 2-4 options max, anchored to entity position. Insert-styled with Araminta's aesthetic. New options (unlocked by knowledge changes) highlighted with gradient glow background. Radial menu reserved for world menu only.
- **Rationale:** Variable-length text options (e.g., confrontation lines in character voice) break radial spatial memory. List handles 1-4 options cleanly. New-item glow signals "something changed" without the UX hazard of geometry transforming under the cursor. Labels render on z-layer 6 (insert overlay) — diegetic test: labels disappear if insert is off.
- **Raised by:** Stig (vertical list structure + diegetic test), Araminta (insert aesthetic). Lead resolved in Stig's favor on structure, Araminta's on styling.
- **Dissent:** Araminta argued for spoke radial (geometry transformation signals qualitative change). Lead rejected — moving goalpost under cursor is bad UX during aiming.
- **References:** Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list).
### D-058: World menu — radial, 4 spokes (perception.md)
- **Decision:** Right-click opens a radial world menu. Four spokes: Observe (eye icon), Insert (phone), Comms (signal), Wait (clock). Drag-release for power users, click-click for newcomers. Insert-styled: geometric lines, thin spokes with icons, nearly transparent. Renders on z-layer 6. v0.1: 2 spokes only (Observe + Insert).
- **Rationale:** Radial works for world menu because items are fixed categories that don't vary by knowledge state. Spatial memory builds quickly (~10 minutes). Scaling to 5-6 spokes is straightforward.
- **Raised by:** Stig (structure + implementation), Araminta (insert aesthetic)
- **Dissent:** None.
### D-059: Fog — shader-based, five layers, knowledge-graph-driven (perception.md)
- **Decision:** Fog is a shader-driven system (not particles) with five distinct layers:
1. Clear (vision cone): soft gradient edge 6-8 sim tiles (= 3-4 visual tiles per D-066), no hard line (Darkwood approach)
2. Light fog (peripheral): desaturated 40-50%, brightness -30%, animated Perlin noise (8-10s cycle), entity D-033 colors visible but reduced
3. Deep fog (explored): near-monochrome, ~10% zone temperature tint (bar=warm, hub=cool, corridor=neutral), more pronounced noise (15-20s cycle)
4. Unexplored + maps app: geometric wireframe outlines #333340
5. Unexplored, no maps: solid near-black #12141a
- Fog is knowledge-graph-driven: same fog shows different information per character based on their KG. "Fog is not darkness — it's the absence of your attention."
- Sound pings: 2-3 concentric expanding rings (sonar-style), insert white-blue. Loud = 3 rings bright fast, quiet = 1 ring faint slow. Fade over 1.5s.
- Recognized entity in fog: D-033 color glow + faint identifying silhouette feature + 0.8s breathing pulse + half-tile position drift (approximate, not exact).
- Unrecognized entity: neutral grey #555566 blob, no features.
- Performance: <1ms/frame total. Vision cone = PointLight2D. Fog = darkness + noise shader on CanvasGroup (Layer 5).
- Soft perception degradation (diegetic scan-line interference) at high overlay load, replacing hard mode cap.
- **Rationale:** Fog must be crucial in v0.1 (lead directive). KG-driven fog is the primary replayability mechanism — same corridor, different character, different information visible. Shader approach confirmed technically trivial by Tyre.
- **Raised by:** Araminta (visual spec), Tyre (technical validation), Nigel (replayability case), Gestalt (signal framework)
- **Dissent:** None.
### D-060: Cognitive delay for fog recognition (perception.md)
- **Decision:** When the player recognizes a heard/sensed entity in fog, recognition is NOT instant. Single cognitive delay: 0.6s base, 0.3s when observe_anomaly triggers (urgent context). Values are tunable. Monologue fires DURING the delay ("Those footsteps... that's Kael's walk"), not after. Visual transition: grey blob to D-033 color + silhouette over ~0.3s within the cognitive delay window. Natural recognition (organic resolve) feels different from sensor recognition (digital snap with biometric ID).
- **Rationale:** Recognition should feel like a cognitive event, not a UI update. The delay creates a moment where the player's brain and the character's brain are working together. Context-sensitive urgency (0.3s for anomalies) prevents the delay from feeling sluggish in tense situations.
- **Raised by:** Ozzie (timing values + urgency split, adopted), Gestalt (longer values, not adopted but playtesting may adjust), Araminta (visual transition spec)
- **Dissent:** Gestalt proposed 0.8-1.2s (longer, more contemplative). Lead chose Ozzie's shorter values as starting point, tunable.
### D-061: Dialogue box — bottom screen, max 20% height, no portraits (perception.md)
- **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width (not percentage-based). Layout: NPC speech top, player response options below, left-aligned. Max 3 response options visible. NO portraits (NPC is on screen). Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually. Walk-away via WASD, dialogue fades over 300ms, no close button.
- **Rationale:** Game world stays live above the dialogue box. The player sees the NPC's body language while talking — no portrait needed. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing.
- **Raised by:** Stig (UI spec), Lead (20% height constraint, max-width directive)
- **Dissent:** Stig initially proposed 25% height and 50% width centered. Lead constrained to 20% height and max-width.
### D-062: Invisible locked dialogue options (content.md)
- **Decision:** Dialogue options the player hasn't unlocked are completely invisible. No grayed-out options. No lock icons. No hint that more options exist. Exception: NPC holding back is communicated via monologue ("She changed the subject. Fast."), not via locked UI elements.
- **Rationale:** Four reasons (Gestalt): (1) Asymmetry — you don't know what you don't know. (2) Dopamine — new options appearing on repeat visits IS the reward. (3) Anti-metagaming — no checklist to complete. (4) Confrontation surprise — Confront option appearing for the first time is a dramatic moment. Strongest consensus point of the entire workshop.
- **Raised by:** Gestalt (proposal + rationale), Nigel (emphatic reinforcement: "non-negotiable")
- **Dissent:** None. Unanimous across all 8 participants.
### D-063: Confrontation — same box, different weight (content.md)
- **Decision:** Confrontation uses the same dialogue UI as casual conversation. No separate confrontation mode. Different weight communicated through: (1) italic text in character's internal voice for confrontation options (regular: "Shift schedule" / confrontation: *"I saw you in corridor B-7"*), (2) pre-delivery monologue beat (1-2s: *"This changes things. No taking it back."*), (3) world responds — NPC shifts to Tier 2 animation, entity D-033 color may fade, monologue frequency spikes, available topics narrow. Walk-away mid-confrontation contaminates social space (NPC routine shifts, KG records incompleteness). v0.1 staging: proximity check + audio dip + text styling. Camera tighten deferred.
- **Rationale:** Confrontation should feel heavy because of what you're saying, not because the UI changed. The weight comes from pacing, voice, and consequence.
- **Explicitly NOT:** Separate confrontation UI, timed responses, visible relationship meter, correct/incorrect approaches.
- **Raised by:** Paula (mechanism), Ozzie (physical staging), Stig (UI container)
- **Dissent:** None.
### D-064: Walk-away — three-phase consequences (content.md)
- **Decision:** Walking away from dialogue (WASD during conversation) triggers three phases: (1) Immediate break — dialogue fades over 300ms, silence. (2) NPC reacts — animation shifts, may call after you, routine may change. (3) KG records — incompleteness is logged (confrontation initiated but not completed), queryable, affects future dialogue/monologue/NPC behavior. Walk-away consequences vary by NPC tolerance threshold per seed — no universal social rules to metagame.
- **Rationale:** Walking away is an action with meaning. Leaving mid-confrontation is different from leaving mid-smalltalk. The KG recording means the game remembers what you started.
- **Raised by:** Paula (three phases), Stig (WASD mechanic, 300ms fade), Ozzie (consequences), Nigel (tolerance per seed)
- **Dissent:** None.
### D-065: Smuggler inventory — knowledge-primary with physical evidence (scope.md)
- **Decision:** Knowledge is the primary "inventory" for all characters. The smuggler additionally gets a minimal physical inventory: 3 specific items in v0.1 (manifest copy, corridor access token, personal comm log). Capacity per archetype: smuggler 3-4 slots, detective 2. Carried items are PRIVATE (info boundary — not visible to other entities unless revealed via search/scan/confrontation). UI: pocket icons bottom-right, 40x40px, no empty slots displayed. Verbs: Take, Place. Server: carried items are world entities with CarriedBy component.
- **Evidence presentation differs by archetype (Paula):** Detective sees case-file-style (structured, what/where/when/source/confidence, insert suggests links). Smuggler sees personal notebook (organized by person, informal voice, no contradiction flags). Same underlying KG, different presentation layer.
- **Rationale:** Smuggler's word doesn't carry institutional weight — they need tangible proof. Detective's word IS evidence (institutional authority), so they're mostly KG-only. Three items demonstrate the risk/reward concept and differentiate the smuggler's gameplay loop.
- **Raised by:** Lead (smuggler needs inventory), Paula (three items + presentation split), Gestalt (knowledge-primary framework), Tyre (minimal implementation), Dudley (server model + info boundary)
- **Dissent:** Tyre initially argued zero physical items in v0.1 (saves 3-4 sprints). Adapted with SmallVec<3> implementation.
---
## Decisions Endorsed (Confirmed from Prior or This Workshop)
These were raised in the workshop and confirmed, but may overlap with or extend existing decisions:
| Topic | Status | Reference |
|-------|--------|-----------|
| Signal/answer framework for information access | New principle — recommend formalization | Gestalt R1, Ozzie R1 |
| Eavesdrop: passive base + positioning enhances quality | Extends D-018 | Dudley R2 (ListeningFocus) |
| Two-phase verb computation (simulation max then observer filter) | Architecture pattern | Dudley R1 |
| SP auto-pause for implant, overlay for MP | Extends D-009 implications | Tyre R1, Lead final |
| Implant organized by data type, not quest | UX principle | Ozzie R1 |
| No highlights on implant data | UX principle | Ozzie R1 |
| Soft perception degradation (diegetic), not hard cap | Extends D-017 | Araminta R2, Lead LD-04 |
| MovementProfile per archetype | Extends D-053 | Dudley R2 |
| Message history: design KG schema now, build display later | Scope constraint | Stig R2, Paula R2, Lead LD-09 |
| Sprint double-take (anomaly monologue survives sprint) | Extends D-055 | Ozzie R2 |
---
## v0.1 Minimum Viable Interaction Model
### Control Map
| Input | Action | Stance/Context | Notes |
|-------|--------|---------------|-------|
| WASD | Move | Current stance | Tile-based, Tween interpolated 100-150ms |
| Toggle key (TBD) | Cycle stances | — | Sprint / Walk / Careful / Crouch |
| Mouse movement | Facing direction | All | Client-side float, server gets octant |
| Left click | Default interaction | Non-weapon | Context: cursor state determines action |
| Left click | Fire | Weapon aim | Cursor = weapon reticle |
| Right click | World radial menu | All | v0.1: 2 spokes (Observe, Insert) |
| Scroll wheel | Zoom | All | Fog boundaries unchanged |
| WASD during dialogue | Walk away | Dialogue open | 300ms fade, 3-phase consequences |
| Shift (with weapon) | Suppress interaction | Weapon selected | Hold to interact while armed |
### Stance Table
| Stance | Speed | Monologue Rate | Noise | Interaction Buffer | v0.1 |
|--------|-------|---------------|-------|--------------------|------|
| Sprint | 1 tile/1 tick | 40% (urgent only) | Loud | CLEARED (D-055) | Yes |
| Walk | 1 tile/2 ticks | 100% (normal) | Normal | Active | Yes |
| Careful | 1 tile/3 ticks | 150% + tell notice bonus | Quiet | Active (enhanced) | Yes |
| Crouch | TBD | TBD | Very quiet | Active | Yes (D-054 occupancy) |
| Prone | TBD | TBD | Minimal | TBD | No (future) |
### Cursor States (D-056)
| State | Visual | Color | z-layer |
|-------|--------|-------|---------|
| Default | Four thin inward ticks, bloom | #c8d0e0 | 7 |
| Entity hover | Expand outward, corner brackets, verb tooltip | D-033 color | 7 |
| Object hover | Rotate 45 degrees (X-shape) | #8b8ba0 / #e8c547 | 7 |
| Weapon aim | Extend, gap widens, 2px, NO bloom | #f0e8d8 | 7 |
### Fog Layers (D-059)
| Layer | Treatment | Key Values |
|-------|-----------|------------|
| Clear | Soft gradient edge | 6-8 sim tiles (= 3-4 visual tiles, D-066) |
| Light fog | Desaturated, Perlin noise | 40-50%, 8-10s cycle |
| Deep fog | Near-monochrome, zone temperature | ~10% tint, 15-20s cycle |
| Unexplored + maps | Geometric wireframe | #333340 |
| Unexplored | Solid black | #12141a |
### Entity Interaction (D-057)
| Element | Spec |
|---------|------|
| Menu type | Vertical list, insert-styled |
| Max options | 2-4, anchored to entity position |
| New option highlight | Gradient glow background |
| Trigger | LOS + ~2 sim tiles proximity (= 1m, D-066) |
| Diegetic test | Labels on z-layer 6, disappear if insert off |
### World Menu (D-058)
| Element | Spec |
|---------|------|
| Menu type | Radial, 4 spokes (v0.1: 2) |
| v0.1 spokes | Observe (eye), Insert (phone) |
| Full spokes | + Comms (signal), Wait (clock) |
| Interaction | Drag-release or click-click |
### Dialogue (D-061, D-062, D-063, D-064)
| Element | Spec |
|---------|------|
| Position | Bottom screen, max 20% height |
| Width | Max-width (not percentage) — value TBD |
| Layout | NPC speech top, options below, left-aligned |
| Max options | 3 visible |
| Locked options | Invisible (D-062) |
| Portraits | None |
| Monologue | Floats above dialogue (z-layer 7) |
| Confrontation | Same box, italic voice, 1-2s monologue beat |
| Walk-away | WASD, 300ms fade, 3-phase consequences |
### Smuggler Inventory (D-065)
| Element | Spec |
|---------|------|
| Model | Knowledge-primary + physical evidence |
| Capacity | Smuggler 3-4, Detective 2 |
| v0.1 items | Manifest copy, corridor access token, personal comm log |
| UI | Pocket icons, bottom-right, 40x40px, no empty slots |
| Verbs | Take, Place |
| Info boundary | Carried items private |
### Fog Recognition (D-060)
| Element | Spec |
|---------|------|
| Cognitive delay | 0.6s base, 0.3s urgent |
| Monologue timing | Fires DURING delay, not after |
| Visual transition | Grey to D-033 color + silhouette over ~0.3s |
| Natural recognition | Organic resolve |
| Sensor recognition | Digital snap with biometric ID |
| Position accuracy | Half-tile drift (approximate) |
### Server Performance Budget
| System | Budget | Source |
|--------|--------|--------|
| Full tick pipeline (9 steps) | <0.5ms/tick | Dudley |
| Fog rendering | <1ms/frame | Tyre |
| Client overlay composition (4 modes) | ~7-11ms/frame | Tyre |
---
## Implementation Priorities
### Priority 1: Core Movement and Interaction (Server)
*Estimate: ~5-6 days server (Tyre)*
1. Stance system (Sprint/Walk/Careful/Crouch) with tick-based movement values
2. MovementProfile component per archetype
3. Sprint interaction buffer suppression (D-055)
4. Same-tile occupancy (TilePresence with posture layers) (~150 lines)
5. Two-phase verb computation extension (ObjectType component, KG-gated Phase 2)
6. Cognitive delay system for fog recognition (0.6s/0.3s, tunable)
### Priority 2: Core UI and Rendering (Client)
*Estimate: ~8-10 days client (Tyre)*
1. Cursor state machine (4 states, Araminta spec, 150ms transitions)
2. Fog shader (5-layer Araminta spec, CanvasGroup Layer 5)
3. Fog entity visualization (sound pings, recognized/unrecognized, cognitive delay animation)
4. Entity interaction vertical list (insert-styled, z-layer 6, diegetic test)
5. World radial menu (2 spokes: Observe, Insert)
6. Dialogue box (max 20% height, max-width, NPC speech + options + monologue)
7. Walk-away mechanic (WASD detection, 300ms fade)
### Priority 3: Smuggler Systems
1. Minimal inventory (SmallVec or BTreeMap, Take/Place, info boundary)
2. Pocket-icon UI (bottom-right, 40x40px, no empty slots)
3. Three smuggler items (manifest copy, access token, comm log) — content definition
4. Contraband detection (NPC scan checks carried items + KG)
### Priority 4: Perception and Audio
1. Eavesdrop positioning (ListeningFocus accumulates stationary_ticks) — deferrable per Dudley
2. Sprint double-take (anomaly monologue survival)
3. 6 new audio assets (cursor hover, weapon mode, fog recognition, implant open/close, dialogue emerge, confrontation dip)
4. Confrontation staging (proximity + audio dip + text styling)
### Priority 5: Deferred (Design Now, Build Later)
1. InteractionMemory KG schema for message history (LD-09)
2. Archetype-specific evidence presentation (detective case file vs smuggler notebook)
3. Pre-dialogue proximity acknowledgment (NPC turns, ambient line)
4. Camera tighten for confrontation staging
5. Prone stance
6. Perception mode soft degradation (scan-line interference)
---
## Open Questions Deferred to Implementation
These questions were not resolved in the workshop and should be addressed during implementation:
| # | Question | Owner Suggestion |
|---|----------|-----------------|
| OQ-01 | ~~Tile size in world units?~~ **Resolved:** 0.5m sim tiles, 1m visual tiles (D-066) | Tyre/Dudley |
| OQ-07 | No insert = no interaction prompts? (Diegetic test implication) | Stig/Gestalt |
| OQ-08 | False-positive fog shapes: gameplay or atmospheric? | Gestalt/Araminta |
| OQ-09 | Zone temperature memory: server-tracked or client-only? | Tyre/Dudley |
| OQ-10 | Sensor recognition "different monologue voice": text styling or narrator tone? | Paula/Stig |
| OQ-13 | No data highlights in implant — how does player know new data was added? | Ozzie/Stig |
| OQ-14 | Scan effectiveness varies per character seed: different data or different actions? | Gestalt/Dudley |
| OQ-18 | Nigel's dialogue access tiers vs D-041 confidence hierarchy: same or layered? | Paula/Gestalt |
| OQ-21 | Archetype evidence presentation: skin on same app or different apps? | Stig/Paula |
| OQ-24 | Inventory capacity: 3 slots (Tyre) or 4 (Dudley)? | Lead call needed |
| OQ-25 | Stance name: "Careful" or "Sneak"? | Lead call needed |
| OQ-26 | Multiplicative perception formula: actual modifier values? | Gestalt/Dudley |
| OQ-27 | Soft perception degradation threshold? | Araminta/Gestalt |
| OQ-28 | InteractionMemory KG schema design? | Paula/Dudley |
| OQ-29 | Dialogue max-width: pixel value? | Stig/Lead |
| OQ-30 | 14 audio assets: who creates them, when in sprint schedule? | Inigo/Si |
---
## Suggested Tickets
For SI to process and assign to sprints:
### Server Team
| # | Title | Scope | Estimate | Depends On |
|---|-------|-------|----------|------------|
| T-1 | Implement stance system (Sprint/Walk/Careful/Crouch) | D-053 | 2-3d | — |
| T-2 | MovementProfile component per archetype | D-053 | 1d | T-1 |
| T-3 | Sprint interaction buffer suppression | D-055 | 0.5d | T-1 |
| T-4 | Same-tile occupancy (TilePresence layers) | D-054 | 1-2d | — |
| T-5 | ObjectType component + verb sets per type | D-057 (server) | 1d | — |
| T-6 | Two-phase verb computation: KG-gated Phase 2 | D-057 (server) | 1-2d | T-5 |
| T-7 | Cognitive delay system for fog recognition | D-060 | 1d | — |
| T-8 | Smuggler inventory (BTreeMap/SmallVec, Take/Place, info boundary) | D-065 | 1-2d | — |
| T-9 | Contraband detection (scan + KG check) | D-065 | 0.5d | T-8 |
| T-10 | ListeningFocus (eavesdrop positioning) | D-053 ext | 1d | T-1 (deferrable) |
| T-11 | Walk-away KG recording (confrontation incompleteness) | D-064 | 0.5d | — |
| T-12 | Sprint anomaly monologue (double-take) | D-055 ext | 0.5d | T-3 |
### Client Team
| # | Title | Scope | Estimate | Depends On |
|---|-------|-------|----------|------------|
| T-13 | Cursor state machine (4 states, Araminta spec) | D-056 | 2d | — |
| T-14 | Fog shader (5-layer, CanvasGroup Layer 5) | D-059 | 3-4d | — |
| T-15 | Fog entity visualization (pings, recognized/unrecognized, delay anim) | D-059, D-060 | 2d | T-14, T-7 |
| T-16 | Entity interaction vertical list (insert-styled) | D-057 | 1-2d | T-13 |
| T-17 | World radial menu (2 spokes v0.1) | D-058 | 1d | — |
| T-18 | Dialogue box (max 20% height, max-width) | D-061 | 2d | — |
| T-19 | Dialogue response selection (max 3, invisible locks) | D-062 | 1d | T-18 |
| T-20 | Confrontation text styling (italic voice, monologue beat) | D-063 | 1d | T-18, T-19 |
| T-21 | Walk-away mechanic (WASD detect, 300ms fade) | D-064 | 0.5d | T-18 |
| T-22 | Smuggler pocket-icon inventory UI | D-065 | 1d | T-8 |
| T-23 | Stance toggle UI (keybind, HUD indicator) | D-053 | 0.5d | T-1 |
### Audio Team
| # | Title | Scope | Estimate | Depends On |
|---|-------|-------|----------|------------|
| T-24 | 6 interaction audio assets | D-059, D-060, D-061 | 2-3d | — |
### Design/Content Team
| # | Title | Scope | Estimate | Depends On |
|---|-------|-------|----------|------------|
| T-25 | Define 3 smuggler item specs (content/attributes/interactions) | D-065 | 1d | — |
| T-26 | InteractionMemory KG schema design | D-064, LD-09 | 1d | — |
| T-27 | Archetype evidence presentation spec (detective case file vs smuggler notebook) | D-065 | 1d | — |
---
## Replayability Validation
Nigel's Round 2 scenario trace validates the v0.1 interaction model against D-027 replayability criteria:
- **10-minute scenario, two characters (smuggler vs detective), same world state**
- **12 points of divergence, zero scripted branching**
- All divergence emerges from knowledge graph differences interacting with: tile-based positioning, fog visibility, verb sets, dialogue access tiers, evidence presentation, and inventory constraints
- Passes the second-playthrough test: playing again as a different character reveals genuinely different information, interactions, and available actions
---
## Cross-Layer Interaction Chains (Workshop Validated)
Five chains documented in workshop notes demonstrate how the layers compose:
1. **Eavesdropping Loop:** Careful stance > tile positioning > ListeningFocus > monologue > KG update > new dialogue options
2. **Fog Recognition Loop:** Sound/sensor detection > cognitive delay (0.6/0.3s) > monologue during delay > KG query > identity resolution or blob remains
3. **Confrontation Chain:** KG contradiction > monologue signal > approach NPC > pre-dialogue beat > dialogue > confront option (italic) > monologue beat > delivery > world responds > KG records
4. **Smuggler Evidence Loop:** Eavesdrop > KG records discrepancy > terminal access > raw data (no highlights) > player notices > Take physical item > info boundary (private) > risk/leverage
5. **Sprint Double-Take:** Sprint through corridor > interaction suppressed > routine monologue suppressed > anomaly detected > delayed monologue > player decides: double back or continue
---
*Workshop closed. 13 decisions produced (D-053 through D-065). 27 suggested tickets across server, client, audio, and design teams. Full discussion notes at `docs/workshops/control-interaction/workshop-notes.md`.*
*Compiled by Qatux.*
+3 -1
View File
@@ -161,10 +161,12 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::trigger_monologue
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::process_sprint_anomaly_monologue
.after(crate::simulation::monologue::trigger_monologue),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::trigger_monologue)
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+173 -4
View File
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 5;
pub const PROTOCOL_VERSION: u8 = 6;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -24,10 +24,11 @@ pub const PROTOCOL_VERSION: u8 = 5;
/// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered).
/// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]).
/// v5 adds: current_monologue (#414 internal monologue pipeline).
/// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 4.
/// Protocol version for forward compatibility. Current: 6.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -35,6 +36,15 @@ pub struct ObserverSnapshot {
pub game_time: GameTime,
/// Player character's facing direction for vision cone (D-015)
pub player_facing: FacingDirection,
/// Player's current movement stance for HUD display (#449, D-053).
/// Defaults to Walk when stance component is absent.
#[serde(default)]
pub player_stance: MovementStance,
/// Items in the player's inventory (#449, D-065).
/// Visible only to this observer per D-010 info boundary.
/// Empty when no CarriedBy component is present.
#[serde(default)]
pub player_inventory: Vec<InventoryItem>,
/// All entities visible to the observer (filtered by LOS + vision cone)
pub entities: Vec<VisibleEntity>,
/// Tiles visible to the observer for fog rendering
@@ -62,6 +72,81 @@ pub struct GameTime {
pub tick_rate: TickRate,
}
/// Player movement stance for tick-based movement speed (#449, D-053).
/// Sprint/Walk/Careful/Crouch affect movement ticks, monologue rate, and
/// interaction buffer availability. Wire format for ObserverSnapshot.
/// v0.1 scope: Sprint/Walk/Careful/Crouch only (Prone deferred).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum MovementStance {
/// 1 tile/tick, monologue at 40%, interaction buffer suppressed (D-055)
Sprint,
/// 1 tile/2 ticks, monologue at 100% (default)
#[default]
Walk,
/// 1 tile/3 ticks, monologue at 150%
Careful,
/// 1 tile/4 ticks, uses Prone/Seated posture layer (D-054)
Crouch,
}
impl MovementStance {
/// Move one step up the stance ladder (toward Sprint).
/// Returns self if already at the top.
pub fn step_up(self) -> Self {
match self {
Self::Crouch => Self::Careful,
Self::Careful => Self::Walk,
Self::Walk => Self::Sprint,
Self::Sprint => Self::Sprint,
}
}
/// Move one step down the stance ladder (toward Crouch).
/// Returns self if already at the bottom.
pub fn step_down(self) -> Self {
match self {
Self::Sprint => Self::Walk,
Self::Walk => Self::Careful,
Self::Careful => Self::Crouch,
Self::Crouch => Self::Crouch,
}
}
/// Ticks per movement step for this stance.
pub fn ticks_per_move(self) -> u32 {
match self {
Self::Sprint => 1,
Self::Walk => 2,
Self::Careful => 3,
Self::Crouch => 4,
}
}
/// Monologue rate multiplier as a percentage (100 = baseline).
/// Sprint suppresses to 40%, Careful enhances to 150% (D-053).
pub fn monologue_rate_percent(self) -> u32 {
match self {
Self::Sprint => 40,
Self::Walk => 100,
Self::Careful => 150,
Self::Crouch => 100,
}
}
}
/// An item in the player's inventory, crossing the wire boundary (#449, D-065).
/// Only items carried by the observer are included (D-010 info boundary).
/// Slot positions map to a 3x3 grid (0-8), 9 slots universal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryItem {
/// Wire-format entity identifier for the item
pub item_id: u64,
/// Display name for inventory UI
pub name: String,
/// Inventory slot index (0-8 for 3x3 grid)
pub slot: u8,
}
/// 8-directional facing direction, matching movement system.
/// Used for vision cone computation (D-015) and snapshot wire format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
@@ -142,6 +227,43 @@ pub enum EntityKind {
Terrain,
}
/// Object type for D-057 Phase 1 verb computation (#421).
///
/// Determines the maximum possible verb set for an interactable world object.
/// NPCs don't use ObjectType — they have their own verb logic (Talk/ExamineNpc).
/// Phase 2 (#422) filters these verbs by the observer's knowledge graph.
///
/// Defined here in bridge::types because it appears on the wire in
/// NearbyInteraction.object_type for Phase 2 context.
/// VerbDef and verb_set() remain in simulation::interaction.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ObjectType {
/// Documents, manifests, notices — can be read
Readable,
/// Crates, lockers, cargo containers — can be opened and searched
Container,
/// Access terminals, comms panels — can be used
Terminal,
/// Doors, hatches, bulkheads — can be opened/closed
Door,
/// Small items that can be picked up (physical inventory, D-065)
Pickup,
/// Chairs, benches, consoles — can be sat at
Furniture,
}
/// Character archetype for Phase 2 verb filtering (#422) and monologue pool
/// selection. Determines how the character perceives and labels interactions.
/// v0.1: Smuggler and Detective (the two playable characters).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum CharacterArchetype {
/// Smuggler character — sees Move/Stash on containers, physical manipulation verbs
Smuggler,
/// Detective character — sees Scan/Flag on containers, investigation verbs
#[default]
Detective,
}
/// Semantic player actions, not raw key events (D-020)
/// Timestamped for deterministic processing
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -174,6 +296,10 @@ pub enum PlayerAction {
Unpause,
/// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052
SetTickRate(TickRate),
/// Move one step up the stance ladder (toward Sprint) per D-053
ToggleStanceUp,
/// Move one step down the stance ladder (toward Crouch) per D-053
ToggleStanceDown,
}
/// Available interaction verbs for a nearby entity (D-060, #404)
@@ -188,6 +314,15 @@ pub struct NearbyInteraction {
pub distance: u32,
/// Available verbs sorted by priority (index 0 = highest priority)
pub verbs: Vec<VerbOption>,
/// Object type for Phase 2 verb filter context (#422).
/// None for NPCs and untyped objects. Enables archetype-specific
/// label remapping (smuggler/detective see different labels for same verb).
#[serde(default)]
pub object_type: Option<ObjectType>,
/// Whether the observer has contradicted knowledge about this entity (#422).
/// Client may render a contradiction indicator (e.g., amber warning icon).
#[serde(default)]
pub contradicted: bool,
}
/// A single available verb on a nearby entity
@@ -203,14 +338,48 @@ pub struct VerbOption {
pub available: bool,
}
/// Verb types for the interaction system (D-060)
/// Verb types for the interaction system (D-057, D-060)
/// Only active verbs appear in verbs[]. Passive (Look, Overhear) and
/// reactive (Monologue) verbs fire independently.
///
/// Phase 1 verbs (simulation, no KG): derived from ObjectType component (#421).
/// Phase 2 verbs (observer, reads KG): filtered/augmented by #422.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VerbKind {
ExamineObject,
// --- NPC verbs ---
/// Observe an NPC (available at mid + close range)
ExamineNpc,
/// Talk to an NPC (close range only)
Talk,
// --- Object verbs (D-057, per ObjectType) ---
/// Generic observation — common to all object types
Observe,
/// Readable objects (manifests, logs, notices)
Read,
/// Container / Door — open it
Open,
/// Door — close it
Close,
/// Container — deeper search (distinct from Open)
Search,
/// Terminal — access logs, comms
Use,
/// Pickup items — physical inventory (D-065)
Take,
/// Furniture — sit/use
Sit,
// --- Phase 2 verbs (observer, KG-gated, #422) ---
/// Confront an NPC about known facts/contradictions.
/// Phase 2 only: injected when observer has KnowsDetails+ confidence.
/// Close range only. Opens confrontation dialogue (D-063).
Confront,
// --- Legacy fallback ---
/// Untyped object examination (entities without ObjectType component).
/// Prefer ObjectType-derived verbs for new content.
ExamineObject,
}
/// Internal monologue event sent to the client for display (#414).
+8 -2
View File
@@ -15,9 +15,10 @@ use settled_reach_server::npc::{
};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState};
use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
@@ -63,7 +64,8 @@ fn main() {
let mut registry = EntityRegistry::new(0);
// Player at (16,16)
// Player at (16,16) — smuggler archetype (#418, D-053)
let profile = MovementProfile::smuggler();
let player = app
.world_mut()
.spawn((
@@ -74,6 +76,10 @@ fn main() {
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
+140 -17
View File
@@ -10,12 +10,15 @@ use bevy_ecs::prelude::*;
use std::collections::HashSet;
use crate::bridge::types::*;
use crate::knowledge::types::KnowledgeState;
use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::monologue::MonologueBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::stance::Stance;
use crate::simulation::time::SimulationTime;
/// Compute visibility geometry using the active perception mode.
@@ -50,7 +53,7 @@ pub fn compute_observer_snapshot(
geometry: Res<VisibilityGeometry>,
registry: Res<EntityRegistry>,
mut observer_query: Query<
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer),
(Entity, &TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer, &mut MonologueBuffer, Option<&Stance>, Option<&CharacterArchetype>, Option<&mut SprintAnomalyQueue>),
With<PlayerCharacter>,
>,
all_entities: Query<(
@@ -59,9 +62,10 @@ pub fn compute_observer_snapshot(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let Ok((_observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer)) =
let Ok((observer_entity, _observer_pos, facing_opt, observer_kg, mut interaction_buffer, mut monologue_buffer, stance_opt, archetype_opt, mut anomaly_queue_opt)) =
observer_query.single_mut()
else {
return;
@@ -71,6 +75,20 @@ pub fn compute_observer_snapshot(
.map(|f| f.0)
.unwrap_or(FacingDirection::default());
let archetype = archetype_opt.copied().unwrap_or_default();
// Collect player inventory (D-065 info boundary: only own items)
let player_inventory = registry
.to_stable(observer_entity)
.map(|player_sid| {
crate::simulation::inventory::collect_inventory_for(
player_sid,
&inventory_items,
&registry,
)
})
.unwrap_or_default();
let (mut entities, visible_ids) =
filter_visible_entities(&geometry, &registry, observer_kg, &all_entities);
@@ -83,6 +101,25 @@ pub fn compute_observer_snapshot(
&mut entities,
);
// Sprint anomaly detection (#428, D-055)
// When sprinting, scan visible entities for Contradicted KG state.
// Queue the first match for delayed "double-take" monologue.
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
if let Some(anomaly_queue) = anomaly_queue_opt.as_mut() {
if !anomaly_queue.has_pending() {
for &wire_id in &visible_ids {
let stable_id = StableId(wire_id);
if let Some(knowledge) = observer_kg.entity_knowledge(&stable_id) {
if knowledge.state == KnowledgeState::Contradicted {
anomaly_queue.push_anomaly(wire_id, time.tick);
break; // First-in wins
}
}
}
}
}
}
let game_time = GameTime {
day: time.day(),
time_of_day: time.time_of_day_minutes(),
@@ -90,9 +127,9 @@ pub fn compute_observer_snapshot(
tick_rate: time.tick_rate,
};
// Take interactions and adjust POI verb priority (D-060)
// Take interactions and apply Phase 2 verb filter (D-057, #422)
let mut nearby_interactions = interaction_buffer.take();
apply_poi_verb_priority(&mut nearby_interactions, observer_kg);
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype);
tracing::trace!(
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
@@ -109,6 +146,8 @@ pub fn compute_observer_snapshot(
tick: time.tick,
game_time,
player_facing: facing,
player_stance: stance_opt.map(|s| s.0).unwrap_or_default(),
player_inventory,
entities,
visible_tiles: geometry.visible_tiles.clone(),
nearby_interactions,
@@ -250,26 +289,110 @@ fn collect_remembered_entities(
}
}
/// Adjust verb priority for PersonOfInterest NPCs (D-060).
/// Moves ExamineNpc to priority 1 and Talk to priority 2 when the observer
/// knows the entity as POI. Called after interaction buffer is taken.
fn apply_poi_verb_priority(
/// Phase 2 verb filter: KG-gated observer-side verb processing (#422, D-057).
///
/// Runs after Phase 1 (simulation-level verb computation) and applies:
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb
///
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
/// to filter, augment, and relabel. This separation keeps D-010 principle 1
/// (info boundary) clean — simulation doesn't know what the observer knows.
fn apply_phase2_verb_filter(
interactions: &mut [NearbyInteraction],
observer_kg: &KnowledgeGraph,
archetype: CharacterArchetype,
) {
for interaction in interactions.iter_mut() {
let stable_id = StableId(interaction.entity_id);
let relationship = observer_kg.relationship_with(&stable_id);
if relationship == RelationshipState::PersonOfInterest {
for verb in &mut interaction.verbs {
match verb.kind {
VerbKind::ExamineNpc => verb.priority = 1,
VerbKind::Talk => verb.priority = 2,
_ => {}
let knowledge = observer_kg.entity_knowledge(&stable_id);
// --- Contradiction marking ---
// If observer's knowledge of this entity is Contradicted, mark the
// interaction. Client renders a visual indicator (D-041).
if let Some(k) = knowledge {
if k.state == KnowledgeState::Contradicted {
interaction.contradicted = true;
}
}
// --- NPC-specific Phase 2 ---
if interaction.entity_type == EntityKind::Npc {
let relationship = observer_kg.relationship_with(&stable_id);
// POI priority flip (D-060): Observe first, Talk second
if relationship == RelationshipState::PersonOfInterest {
for verb in &mut interaction.verbs {
match verb.kind {
VerbKind::ExamineNpc => verb.priority = 1,
VerbKind::Talk => verb.priority = 2,
_ => {}
}
}
}
// Confront injection: available when observer has KnowsDetails+
// on this NPC and is at close range (distance ≤ 2).
if interaction.distance <= 2 {
let has_details = knowledge
.map(|k| k.confidence >= KnowledgeConfidence::KnowsDetails)
.unwrap_or(false);
if has_details {
// Priority 3 = after Talk/ExamineNpc in normal case,
// after ExamineNpc/Talk in POI case. Always the escalation option.
interaction.verbs.push(VerbOption {
kind: VerbKind::Confront,
label: "Confront".into(),
priority: 3,
available: true,
});
}
}
interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8));
}
// --- Archetype label relabeling ---
// Phase 2 swaps verb labels based on character archetype.
// The VerbKind stays the same (same handler), only the display label changes.
// This implements D-057: "Character differentiation via Phase 2 observer
// filter, not separate verb systems."
for verb in &mut interaction.verbs {
if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind) {
verb.label = label.into();
}
}
// Re-sort after priority changes and verb additions
interaction.verbs.sort_by_key(|v| (v.priority, v.kind as u8));
}
}
/// Archetype-specific verb label overrides (#422, D-057).
///
/// Returns a replacement label for the given (archetype, object_type, verb_kind)
/// combination, or None to keep the Phase 1 default label.
///
/// v0.1: Container verbs differ by archetype. Other object types keep defaults.
/// Add match arms here for future archetype-specific labels.
fn archetype_verb_label(
archetype: CharacterArchetype,
object_type: Option<ObjectType>,
kind: VerbKind,
) -> Option<&'static str> {
match (archetype, object_type, kind) {
// Smuggler: Container verbs — physical manipulation vocabulary
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"),
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => Some("Stash"),
// Detective: Container verbs — investigation vocabulary
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => Some("Scan"),
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => Some("Flag"),
// All other combinations: keep Phase 1 default label
_ => None,
}
}
File diff suppressed because it is too large Load Diff
+678 -19
View File
@@ -1,10 +1,15 @@
// Input processing system
// Timestamped player input events for deterministic simulation (D-010 principle 4)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
use crate::bridge::types::{PlayerAction, PlayerInput};
use crate::knowledge::{EntityRegistry, StableId};
use crate::perception::vision_cone::{facing_from_delta, Facing};
use crate::simulation::inventory::{
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
};
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate};
use bevy_ecs::prelude::*;
use std::collections::VecDeque;
@@ -55,25 +60,77 @@ impl InputQueue {
}
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
#[allow(clippy::type_complexity)]
pub fn process_player_input(
mut input_queue: ResMut<InputQueue>,
mut time: ResMut<SimulationTime>,
mut commands: Commands,
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
registry: Res<EntityRegistry>,
mut player_query: Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
) {
let current_tick = time.tick;
let inputs = input_queue.drain_for_tick(current_tick);
// Track whether any movement was attempted this tick (for cooldown tick advance)
let mut move_attempted = false;
for input in inputs {
match input.action {
PlayerAction::MoveNorth => apply_move(&player_query, &mut commands, 0, -1),
PlayerAction::MoveSouth => apply_move(&player_query, &mut commands, 0, 1),
PlayerAction::MoveEast => apply_move(&player_query, &mut commands, 1, 0),
PlayerAction::MoveWest => apply_move(&player_query, &mut commands, -1, 0),
PlayerAction::MoveNortheast => apply_move(&player_query, &mut commands, 1, -1),
PlayerAction::MoveNorthwest => apply_move(&player_query, &mut commands, -1, -1),
PlayerAction::MoveSoutheast => apply_move(&player_query, &mut commands, 1, 1),
PlayerAction::MoveSouthwest => apply_move(&player_query, &mut commands, -1, 1),
PlayerAction::MoveNorth => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, 0, -1);
}
PlayerAction::MoveSouth => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, 0, 1);
}
PlayerAction::MoveEast => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, 1, 0);
}
PlayerAction::MoveWest => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, -1, 0);
}
PlayerAction::MoveNortheast => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, 1, -1);
}
PlayerAction::MoveNorthwest => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, -1, -1);
}
PlayerAction::MoveSoutheast => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, 1, 1);
}
PlayerAction::MoveSouthwest => {
move_attempted = true;
apply_move(&mut player_query, &mut commands, -1, 1);
}
PlayerAction::ToggleStanceUp => {
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
let new_stance = stance.0.step_up();
if new_stance != stance.0 {
tracing::debug!("Stance up: {:?} -> {:?}", stance.0, new_stance);
stance.0 = new_stance;
}
}
}
PlayerAction::ToggleStanceDown => {
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
let new_stance = stance.0.step_down();
if new_stance != stance.0 {
tracing::debug!("Stance down: {:?} -> {:?}", stance.0, new_stance);
stance.0 = new_stance;
}
}
}
PlayerAction::Pause => {
time.tick_rate = TickRate::Paused;
tracing::debug!("Simulation paused by player input");
@@ -86,29 +143,76 @@ pub fn process_player_input(
time.tick_rate = rate;
tracing::debug!("Tick rate set to {:?} by player input", rate);
}
PlayerAction::Interact { target_entity_id, verb } => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
PlayerAction::Interact { target_entity_id, ref verb } => {
match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
&registry,
&player_query,
&inventory_items,
target_entity_id,
);
}
Some("Place") => {
handle_place(
&mut commands,
&registry,
&player_query,
target_entity_id,
);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
target_entity_id,
verb,
);
}
}
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
}
}
// If no movement was attempted this tick, still advance cooldown counter
if !move_attempted {
if let Ok((_, _, _, Some(mut cooldown))) = player_query.single_mut() {
cooldown.tick();
}
}
}
/// Apply a movement action with stance-based cooldown enforcement.
/// If the player has a Stance and PlayerMoveCooldown, movement is throttled
/// according to the stance's ticks_per_move. Without these components,
/// movement is unrestricted (backward compatibility).
#[allow(clippy::type_complexity)]
fn apply_move(
player_query: &Query<(Entity, &TilePosition), With<PlayerCharacter>>,
player_query: &mut Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
commands: &mut Commands,
dx: i32,
dy: i32,
) {
let (entity, pos) = player_query
.single()
let (entity, pos, stance_opt, cooldown_opt) = player_query
.single_mut()
.expect("PlayerCharacter entity must exist when processing input");
let stance = stance_opt.map(|s| s.0).unwrap_or_default();
// Check cooldown if present
if let Some(mut cooldown) = cooldown_opt {
if !cooldown.try_move(stance) {
tracing::trace!("Movement throttled by stance {:?} cooldown", stance);
return;
}
}
commands.entity(entity).insert(MoveIntent {
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
});
@@ -118,6 +222,105 @@ fn apply_move(
.insert(Facing(facing_from_delta(dx, dy)));
}
/// Handle Take verb: pick up an item entity and add it to the player's inventory.
/// Removes TilePosition (item is no longer on the ground — info boundary enforcement),
/// adds CarriedBy + InventorySlot components.
#[allow(clippy::type_complexity)]
fn handle_take(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Take verb without target_entity_id");
return;
};
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
let Some(player_sid) = registry.to_stable(player_entity) else {
tracing::error!("Player entity not in EntityRegistry");
return;
};
// Resolve wire ID to ECS entity
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Take: target entity not in registry");
return;
};
// Check inventory capacity
let occupied = occupied_slots_for(player_sid, inventory_items);
let Some(slot) = find_next_slot(&occupied) else {
tracing::info!("Inventory full ({} slots), cannot take item", MAX_INVENTORY_SLOTS);
return;
};
// Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot
commands.entity(target_entity)
.remove::<TilePosition>()
.insert((CarriedBy(player_sid), InventorySlot(slot)));
tracing::info!(
target_id,
slot,
"Take: item picked up and added to inventory slot",
);
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
#[allow(clippy::type_complexity)]
fn handle_place(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>),
With<PlayerCharacter>,
>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Place verb without target_entity_id");
return;
};
let Ok((_, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Place: target entity not in registry");
return;
};
let place_pos = *player_pos;
// Remove inventory components, place item at player's tile
commands.entity(target_entity)
.remove::<CarriedBy>()
.remove::<InventorySlot>()
.insert(place_pos);
tracing::info!(
target_id,
x = place_pos.x,
y = place_pos.y,
z = place_pos.z,
"Place: item dropped at player position",
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -168,6 +371,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -191,6 +395,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -209,6 +414,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -228,6 +434,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
@@ -244,6 +451,7 @@ mod tests {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
@@ -261,4 +469,455 @@ mod tests {
// No MoveIntent should be created (input for future tick)
assert!(world.get::<MoveIntent>(player).is_none());
}
use crate::bridge::types::MovementStance;
#[test]
fn toggle_stance_up_changes_stance() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Stance::default(),
PlayerMoveCooldown::default(),
));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ToggleStanceUp,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let mut query = world.query::<&Stance>();
let stance = query.single(&world).unwrap();
assert_eq!(stance.0, MovementStance::Sprint);
}
#[test]
fn toggle_stance_down_changes_stance() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Stance::default(),
PlayerMoveCooldown::default(),
));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ToggleStanceDown,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let mut query = world.query::<&Stance>();
let stance = query.single(&world).unwrap();
assert_eq!(stance.0, MovementStance::Careful);
}
#[test]
fn walk_stance_throttles_movement_to_every_2_ticks() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Stance::default(), // Walk
PlayerMoveCooldown::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Tick 0: move north — should succeed (first move)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some(), "first move should succeed");
// Remove MoveIntent (simulating validate_movement consuming it)
world.entity_mut(player).remove::<MoveIntent>();
// Tick 0 again: move north — should be throttled (cooldown)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_none(), "second move should be throttled");
// Tick 0 again: move north — should succeed (cooldown elapsed)
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some(), "third move should succeed after cooldown");
}
#[test]
fn sprint_stance_allows_every_tick() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Stance(MovementStance::Sprint),
PlayerMoveCooldown::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// First move
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
world.entity_mut(player).remove::<MoveIntent>();
// Second move — sprint allows every tick
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some(), "sprint should allow every tick");
}
#[test]
fn no_stance_component_moves_unrestricted() {
// Backward compatibility: entities without Stance/Cooldown move freely
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
world.entity_mut(player).remove::<MoveIntent>();
// Second move immediately — no throttle without components
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(world.get::<MoveIntent>(player).is_some());
}
#[test]
fn take_verb_picks_up_item() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Spawn player and register
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Spawn item near player
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Manifest Copy".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
// Issue Take verb
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item should have CarriedBy + InventorySlot, no TilePosition
assert!(world.get::<TilePosition>(item).is_none(), "item should leave the ground");
let carried = world.get::<CarriedBy>(item).expect("item should have CarriedBy");
assert_eq!(carried.0, player_sid);
let slot = world.get::<InventorySlot>(item).expect("item should have slot");
assert_eq!(slot.0, 0, "first item goes to slot 0");
}
#[test]
fn place_verb_drops_item() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Spawn item already in inventory (no TilePosition)
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
// Issue Place verb
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Place".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item should have TilePosition at player's location, no CarriedBy/InventorySlot
let pos = world.get::<TilePosition>(item).expect("item should be on ground");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed");
}
#[test]
fn take_verb_assigns_sequential_slots() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Item already in slot 0
world.spawn((
CarriedBy(player_sid),
ItemName("Manifest".into()),
InventorySlot(0),
));
// New item on the ground
let item2 = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Token".into()),
))
.id();
let item2_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item2);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item2_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let slot = world.get::<InventorySlot>(item2).expect("item should have slot");
assert_eq!(slot.0, 1, "second item goes to slot 1");
}
#[test]
fn take_verb_full_inventory_rejected() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
// Fill all 9 slots
for slot in 0..MAX_INVENTORY_SLOTS {
world.spawn((
CarriedBy(player_sid),
ItemName(format!("Item {}", slot)),
InventorySlot(slot),
));
}
// Try to take another item
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Overflow".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item should still be on the ground
assert!(world.get::<TilePosition>(item).is_some(), "item stays on ground");
assert!(world.get::<CarriedBy>(item).is_none(), "no CarriedBy when full");
}
#[test]
fn take_then_place_roundtrip() {
// D-065: full cycle — item on ground → Take → carried → Place → ground again
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let player_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(player);
let item = world
.spawn((
TilePosition::new(5, 4, 0),
ItemName("Manifest Copy".into()),
))
.id();
let item_sid = world.resource_mut::<crate::knowledge::EntityRegistry>().register(item);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: Take
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
schedule.run(&mut world);
assert!(world.get::<TilePosition>(item).is_none(), "item off ground after Take");
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
assert_eq!(world.get::<InventorySlot>(item).unwrap().0, 0);
// Step 2: Place
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Place".into()),
},
});
world.resource_mut::<SimulationTime>().tick = 1;
schedule.run(&mut world);
let pos = world.get::<TilePosition>(item).expect("item back on ground after Place");
assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position");
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed after Place");
assert!(world.get::<InventorySlot>(item).is_none(), "InventorySlot removed after Place");
}
#[test]
fn take_without_target_id_is_noop() {
// Edge case: Take verb with no target_entity_id should not panic
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: None,
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
#[test]
fn place_without_target_id_is_noop() {
// Edge case: Place verb with no target_entity_id should not panic
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: None,
verb: Some("Place".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
}
+675 -42
View File
@@ -1,17 +1,23 @@
// Interaction system — proximity detection + multi-verb InteractionOptions
// Implements #404: server-side verb computation for context-sensitive [E] key
// Extended by #421: ObjectType component + verb sets per type (D-057)
// Spec: docs/design/interaction-verbs-v0.1.md
// D-060: actions[] renamed to verbs[] across all surfaces
//
// Phase boundary: this system determines verb AVAILABILITY based on proximity
// and entity type only. Verb PRIORITY adjustment (e.g. POI flipping Observe
// above Talk) is a perception concern handled by the observer system.
// Phase 2 filtering (KG-gated verbs) handled by #422.
use bevy_ecs::prelude::*;
use crate::bridge::types::{EntityKind, NearbyInteraction, VerbKind, VerbOption};
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
pub use crate::bridge::types::ObjectType;
use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption};
use crate::knowledge::EntityRegistry;
use crate::npc::Npc;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::stance::Stance;
/// Interaction range thresholds (Manhattan distance, same z-level)
pub(crate) const CLOSE_RANGE: u32 = 2;
@@ -22,32 +28,97 @@ pub(crate) const MID_RANGE: u32 = 5;
#[derive(Component, Debug, Clone)]
pub struct Interactable;
// ObjectType enum is defined in bridge::types (wire type for NearbyInteraction).
// VerbDef and verb_set() impl remain here in the simulation layer.
/// A verb definition in an ObjectType's Phase 1 verb set.
#[derive(Debug, Clone, Copy)]
pub struct VerbDef {
pub kind: VerbKind,
pub label: &'static str,
pub priority: u8,
/// Whether this verb requires close range (true) or works at mid range (false)
pub close_only: bool,
}
impl ObjectType {
/// Phase 1 verb set: maximum possible verbs for this object type (D-057).
/// No KG dependency — this is simulation-level verb computation.
/// Phase 2 (#422) will filter these by the observer's knowledge.
///
/// Each verb has a default priority and range requirement:
/// - Primary verbs (priority 1-2): the main actions for this type
/// - Observe (priority 3): always available, works at mid range
pub fn verb_set(&self) -> &'static [VerbDef] {
match self {
Self::Readable => &[
VerbDef { kind: VerbKind::Read, label: "Read", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
Self::Container => &[
VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Search, label: "Search", priority: 2, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false },
],
Self::Terminal => &[
VerbDef { kind: VerbKind::Use, label: "Use", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
Self::Door => &[
VerbDef { kind: VerbKind::Open, label: "Open", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Close, label: "Close", priority: 2, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 3, close_only: false },
],
Self::Pickup => &[
VerbDef { kind: VerbKind::Take, label: "Take", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
Self::Furniture => &[
VerbDef { kind: VerbKind::Sit, label: "Sit", priority: 1, close_only: true },
VerbDef { kind: VerbKind::Observe, label: "Observe", priority: 2, close_only: false },
],
}
}
}
/// Compute nearby interactions for the player character.
/// For each entity in range, determines available verbs sorted by priority.
/// Results are written to the NearbyInteractionBuffer for inclusion in ObserverSnapshot.
///
/// Verb source priority:
/// 1. NPCs: Talk + ExamineNpc (hardcoded, NPC-specific)
/// 2. ObjectType: type-specific verb set from verb_set() (D-057 Phase 1)
/// 3. Untyped objects: ExamineObject fallback (legacy/simple objects)
///
/// NOTE: Determines verb availability and default priority only. Relationship-based
/// priority adjustment (e.g. POI Observe first) is applied by the observer
/// priority adjustment (e.g. POI -> Observe first) is applied by the observer
/// system after taking the buffer. This keeps the simulation phase free of
/// knowledge graph dependencies (D-010 phase boundary).
#[allow(clippy::type_complexity)]
pub fn compute_nearby_interactions(
mut player_query: Query<
(&TilePosition, &mut NearbyInteractionBuffer),
(&TilePosition, &mut NearbyInteractionBuffer, Option<&Stance>),
With<PlayerCharacter>,
>,
registry: Res<EntityRegistry>,
interactables: Query<
(Entity, &TilePosition, Option<&Npc>),
(Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>),
(With<Interactable>, Without<PlayerCharacter>),
>,
) {
let Ok((player_pos, mut buffer)) = player_query.single_mut() else {
let Ok((player_pos, mut buffer, stance_opt)) = player_query.single_mut() else {
return;
};
buffer.interactions.clear();
for (entity, pos, is_npc) in interactables.iter() {
// D-055: Sprint explicitly suppresses interaction buffer.
// No interaction verbs are computed or sent to the client during sprint.
// Anomaly monologue survives sprint (handled by separate monologue system).
if stance_opt.map(|s| s.0) == Some(MovementStance::Sprint) {
return;
}
for (entity, pos, is_npc, object_type) in interactables.iter() {
let Some(distance) = player_pos.manhattan_distance(pos) else {
continue; // Different z-level
};
@@ -65,44 +136,55 @@ pub fn compute_nearby_interactions(
let is_close = distance <= CLOSE_RANGE;
let mut verbs = Vec::new();
match entity_type {
EntityKind::Npc => {
if is_close {
// Default priority: Talk first, Observe second.
// Observer adjusts priority for POI entities.
verbs.push(VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
});
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 2,
available: true,
});
} else {
// Mid range: only Examine NPC (Talk requires close range)
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 1,
available: true,
});
}
if is_npc.is_some() {
// NPC verb logic — unchanged from #404
if is_close {
// Default priority: Talk first, Observe second.
// Observer adjusts priority for POI entities.
verbs.push(VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
});
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 2,
available: true,
});
} else {
// Mid range: only Examine NPC (Talk requires close range)
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
priority: 1,
available: true,
});
}
EntityKind::Object | EntityKind::Terrain => {
if is_close {
verbs.push(VerbOption {
kind: VerbKind::ExamineObject,
label: "Examine".into(),
priority: 1,
available: true,
});
} else if let Some(obj_type) = object_type {
// D-057 Phase 1: ObjectType-driven verb set (#421)
for def in obj_type.verb_set() {
if def.close_only && !is_close {
continue; // Skip close-only verbs when at mid range
}
verbs.push(VerbOption {
kind: def.kind,
label: def.label.into(),
priority: def.priority,
available: true,
});
}
} else {
// Legacy fallback: untyped object (no ObjectType component)
if is_close {
verbs.push(VerbOption {
kind: VerbKind::ExamineObject,
label: "Examine".into(),
priority: 1,
available: true,
});
}
EntityKind::Player => {} // No self-interaction
}
if verbs.is_empty() {
@@ -128,6 +210,8 @@ pub fn compute_nearby_interactions(
entity_type,
distance,
verbs,
object_type: object_type.copied(),
contradicted: false, // Phase 2 sets this from KG
});
}
@@ -185,6 +269,10 @@ mod tests {
query.single(world).unwrap()
}
// -----------------------------------------------------------------------
// NPC verb tests (unchanged from #404)
// -----------------------------------------------------------------------
#[test]
fn npc_in_close_range_gets_talk_and_observe() {
let mut world = setup_world();
@@ -235,8 +323,186 @@ mod tests {
assert!(buffer.interactions.is_empty());
}
// -----------------------------------------------------------------------
// ObjectType verb tests (#421)
// -----------------------------------------------------------------------
#[test]
fn object_in_close_range_gets_examine() {
fn readable_close_range_gets_read_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Readable,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Read);
assert_eq!(buffer.interactions[0].verbs[0].label, "Read");
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
assert_eq!(buffer.interactions[0].verbs[1].label, "Observe");
}
#[test]
fn readable_mid_range_gets_observe_only() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 9, 0),
Interactable,
ObjectType::Readable,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe);
}
#[test]
fn container_close_range_gets_open_search_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Container,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 3);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Search);
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe);
}
#[test]
fn terminal_close_range_gets_use_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Terminal,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Use);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
}
#[test]
fn door_close_range_gets_open_close_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Door,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 3);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Open);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Close);
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Observe);
}
#[test]
fn pickup_close_range_gets_take_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Pickup,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Take);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
}
#[test]
fn furniture_close_range_gets_sit_and_observe() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Furniture,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Sit);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Observe);
}
#[test]
fn container_mid_range_gets_observe_only() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
TilePosition::new(5, 9, 0),
Interactable,
ObjectType::Container,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 1);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Observe);
}
// -----------------------------------------------------------------------
// Legacy untyped object fallback
// -----------------------------------------------------------------------
#[test]
fn untyped_object_in_close_range_gets_examine() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((TilePosition::new(5, 6, 0), Interactable));
@@ -251,6 +517,25 @@ mod tests {
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject);
}
#[test]
fn untyped_object_mid_range_no_verbs() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((TilePosition::new(5, 9, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
// Untyped objects have no mid-range verbs
assert!(buffer.interactions.is_empty());
}
// -----------------------------------------------------------------------
// General interaction tests
// -----------------------------------------------------------------------
#[test]
fn different_z_level_no_interactions() {
let mut world = setup_world();
@@ -310,4 +595,352 @@ mod tests {
assert_eq!(buffer.interactions.len(), 2);
assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance);
}
#[test]
fn mixed_npcs_and_objects_all_detected() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
// NPC nearby
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
// Typed object nearby
world.spawn((
TilePosition::new(6, 5, 0),
Interactable,
ObjectType::Terminal,
));
// Untyped object nearby
world.spawn((TilePosition::new(4, 5, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 3);
// All should be at distance 1
for interaction in &buffer.interactions {
assert_eq!(interaction.distance, 1);
}
}
// -----------------------------------------------------------------------
// ObjectType::verb_set() unit tests
// -----------------------------------------------------------------------
#[test]
fn verb_set_readable_has_read_and_observe() {
let verbs = ObjectType::Readable.verb_set();
assert_eq!(verbs.len(), 2);
assert_eq!(verbs[0].kind, VerbKind::Read);
assert!(verbs[0].close_only);
assert_eq!(verbs[1].kind, VerbKind::Observe);
assert!(!verbs[1].close_only);
}
#[test]
fn verb_set_container_has_three_verbs() {
let verbs = ObjectType::Container.verb_set();
assert_eq!(verbs.len(), 3);
assert_eq!(verbs[0].kind, VerbKind::Open);
assert_eq!(verbs[1].kind, VerbKind::Search);
assert_eq!(verbs[2].kind, VerbKind::Observe);
}
#[test]
fn all_object_types_have_observe() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let verbs = obj_type.verb_set();
let has_observe = verbs.iter().any(|v| v.kind == VerbKind::Observe);
assert!(has_observe, "{:?} should have Observe verb", obj_type);
}
}
#[test]
fn all_object_types_observe_is_mid_range() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let verbs = obj_type.verb_set();
let observe = verbs.iter().find(|v| v.kind == VerbKind::Observe).unwrap();
assert!(
!observe.close_only,
"{:?}'s Observe should be available at mid range",
obj_type
);
}
}
// -----------------------------------------------------------------------
// Additional QA coverage (Hoshe, Sprint 6)
// -----------------------------------------------------------------------
/// All ObjectType variants at mid range should produce Observe only.
/// Covers gap: only Readable + Container had explicit mid-range tests.
#[test]
fn all_object_types_mid_range_observe_only() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
// Distance 4 = mid range (> CLOSE_RANGE=2, <= MID_RANGE=5)
world.spawn((
TilePosition::new(5, 9, 0),
Interactable,
obj_type,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(
buffer.interactions.len(), 1,
"{:?} at mid range should produce 1 interaction", obj_type
);
assert_eq!(
buffer.interactions[0].verbs.len(), 1,
"{:?} at mid range should have exactly 1 verb (Observe)", obj_type
);
assert_eq!(
buffer.interactions[0].verbs[0].kind, VerbKind::Observe,
"{:?} at mid range verb should be Observe", obj_type
);
}
}
/// ObjectType entity beyond MID_RANGE produces no interactions.
#[test]
fn object_type_out_of_range_no_interactions() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
// Distance 6 = beyond MID_RANGE=5
world.spawn((
TilePosition::new(5, 11, 0),
Interactable,
ObjectType::Container,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty());
}
/// Entity with both Npc and ObjectType: NPC verbs take priority.
/// ObjectType verbs should NOT appear — NPCs have their own verb logic.
#[test]
fn npc_with_object_type_uses_npc_verbs() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((
Npc,
TilePosition::new(5, 6, 0),
Interactable,
ObjectType::Terminal, // Should be ignored
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
// Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe)
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
}
/// All ObjectType primary verbs are close_only (except Observe).
/// Ensures no accidental mid-range primary actions.
#[test]
fn all_primary_verbs_are_close_only() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
for def in obj_type.verb_set() {
if def.kind == VerbKind::Observe {
assert!(!def.close_only, "{:?} Observe should be mid-range", obj_type);
} else {
assert!(
def.close_only,
"{:?} {:?} should be close-only", obj_type, def.kind
);
}
}
}
}
/// D-057: max 4 verbs per entity. Verify no ObjectType exceeds this.
#[test]
fn verb_set_max_four_verbs() {
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let verbs = obj_type.verb_set();
assert!(
verbs.len() <= 4,
"{:?} has {} verbs, D-057 max is 4", obj_type, verbs.len()
);
}
}
// -----------------------------------------------------------------------
// Sprint interaction buffer suppression (#419, D-055)
// -----------------------------------------------------------------------
/// Spawn player with Stance component for sprint suppression tests.
fn spawn_player_with_stance(world: &mut World, x: i32, y: i32, stance: MovementStance) -> Entity {
world
.spawn((
PlayerCharacter,
TilePosition::new(x, y, 0),
NearbyInteractionBuffer::default(),
Stance(stance),
))
.id()
}
#[test]
fn sprint_suppresses_npc_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress all interactions");
}
#[test]
fn sprint_suppresses_object_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((TilePosition::new(5, 6, 0), Interactable, ObjectType::Terminal));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress object interactions");
}
#[test]
fn walk_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Walk);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Walk should allow interactions");
}
#[test]
fn careful_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Careful);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Careful should allow interactions");
}
#[test]
fn crouch_stance_allows_interactions() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Crouch);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "Crouch should allow interactions");
}
#[test]
fn no_stance_component_allows_interactions() {
// Backward compatibility: players without Stance still get interactions
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1, "no Stance component should allow interactions");
}
#[test]
fn sprint_suppresses_multiple_nearby_entities() {
let mut world = setup_world();
spawn_player_with_stance(&mut world, 5, 5, MovementStance::Sprint);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
world.spawn((TilePosition::new(6, 5, 0), Interactable, ObjectType::Container));
world.spawn((TilePosition::new(4, 5, 0), Interactable));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_nearby_interactions);
schedule.run(&mut world);
let buffer = read_buffer(&mut world);
assert!(buffer.interactions.is_empty(), "sprint should suppress all 3 nearby entities");
}
}
+272
View File
@@ -0,0 +1,272 @@
// Inventory system — CarriedBy component, Take/Place verb handling (#424, D-065)
//
// Items are world entities with CarriedBy(StableId) referencing the carrier.
// When carried, TilePosition is removed — this naturally enforces the info
// boundary: carried items don't appear in spatial queries (visibility,
// interactions) for other observers. Only the carrier's observer snapshot
// includes them via player_inventory.
//
// Take: removes TilePosition, adds CarriedBy + InventorySlot.
// Place: removes CarriedBy + InventorySlot, adds TilePosition at player pos.
//
// 3x3 grid = 9 universal slots (OQ-24 resolved). Slot assignment is
// first-available (0..8).
use bevy_ecs::prelude::*;
use crate::bridge::types::InventoryItem;
use crate::knowledge::types::StableId;
/// Maximum inventory slots (3x3 grid, OQ-24).
pub const MAX_INVENTORY_SLOTS: u8 = 9;
/// Marks an item as carried by an entity. References the carrier's StableId.
/// When present, the item entity should NOT have a TilePosition — it's
/// in someone's pocket, not on the ground.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct CarriedBy(pub StableId);
/// Display name for an item entity, crossing the wire as InventoryItem.name.
#[derive(Component, Debug, Clone)]
pub struct ItemName(pub String);
/// Inventory slot assignment (0..8 for 3x3 grid).
/// Assigned on Take, removed on Place.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct InventorySlot(pub u8);
/// Find the next available inventory slot for a carrier.
/// Returns None if all 9 slots are occupied.
pub fn find_next_slot(occupied: &[u8]) -> Option<u8> {
for slot in 0..MAX_INVENTORY_SLOTS {
if !occupied.contains(&slot) {
return Some(slot);
}
}
None
}
/// Collect inventory items for a specific carrier (by StableId).
/// Returns wire-format InventoryItem structs sorted by slot.
pub fn collect_inventory_for(
carrier_id: StableId,
items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
registry: &crate::knowledge::EntityRegistry,
) -> Vec<InventoryItem> {
let mut result: Vec<InventoryItem> = items
.iter()
.filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id)
.map(|(entity, _, name, slot)| {
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "carried item not in EntityRegistry");
entity.to_bits()
});
InventoryItem {
item_id: wire_id,
name: name.0.clone(),
slot: slot.0,
}
})
.collect();
result.sort_by_key(|item| item.slot);
result
}
/// Get the list of occupied slots for a carrier.
pub fn occupied_slots_for(
carrier_id: StableId,
items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
) -> Vec<u8> {
items
.iter()
.filter(|(_, carried_by, _, _)| carried_by.0 == carrier_id)
.map(|(_, _, _, slot)| slot.0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::EntityRegistry;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
#[test]
fn find_next_slot_empty_returns_zero() {
assert_eq!(find_next_slot(&[]), Some(0));
}
#[test]
fn find_next_slot_skips_occupied() {
assert_eq!(find_next_slot(&[0, 1, 2]), Some(3));
}
#[test]
fn find_next_slot_fills_gaps() {
assert_eq!(find_next_slot(&[0, 2, 4]), Some(1));
}
#[test]
fn find_next_slot_full_returns_none() {
let all: Vec<u8> = (0..9).collect();
assert_eq!(find_next_slot(&all), None);
}
#[test]
fn collect_inventory_empty_when_no_items() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
let mut query_state =
world.query::<(Entity, &CarriedBy, &ItemName, &InventorySlot)>();
// Can't use system params directly in tests — use world query
// Instead, verify the logic by spawning items and checking
assert_eq!(query_state.iter(&world).count(), 0);
let _ = player_sid; // used for the filter
}
#[test]
fn carried_item_appears_in_inventory() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
let item = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item);
// Use system_state for proper Query access
let mut system_state = bevy_ecs::system::SystemState::<(
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
Res<EntityRegistry>,
)>::new(&mut world);
let (items_query, registry) = system_state.get(&world);
let inventory = collect_inventory_for(player_sid, &items_query, &registry);
assert_eq!(inventory.len(), 1);
assert_eq!(inventory[0].name, "Manifest Copy");
assert_eq!(inventory[0].slot, 0);
}
#[test]
fn only_own_items_in_inventory() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
let other = world.spawn_empty().id();
let other_sid = world.resource_mut::<EntityRegistry>().register(other);
// Player's item
let item1 = world
.spawn((
CarriedBy(player_sid),
ItemName("Manifest Copy".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item1);
// Other entity's item — should NOT appear in player's inventory
let item2 = world
.spawn((
CarriedBy(other_sid),
ItemName("Access Token".into()),
InventorySlot(0),
))
.id();
world.resource_mut::<EntityRegistry>().register(item2);
let mut system_state = bevy_ecs::system::SystemState::<(
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
Res<EntityRegistry>,
)>::new(&mut world);
let (items_query, registry) = system_state.get(&world);
let inventory = collect_inventory_for(player_sid, &items_query, &registry);
assert_eq!(inventory.len(), 1, "info boundary: only own items");
assert_eq!(inventory[0].name, "Manifest Copy");
}
#[test]
fn inventory_sorted_by_slot() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
// Spawn items in reverse slot order
for (slot, name) in [(2, "Comm Log"), (0, "Manifest"), (1, "Token")] {
let item = world
.spawn((
CarriedBy(player_sid),
ItemName(name.into()),
InventorySlot(slot),
))
.id();
world.resource_mut::<EntityRegistry>().register(item);
}
let mut system_state = bevy_ecs::system::SystemState::<(
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
Res<EntityRegistry>,
)>::new(&mut world);
let (items_query, registry) = system_state.get(&world);
let inventory = collect_inventory_for(player_sid, &items_query, &registry);
assert_eq!(inventory.len(), 3);
assert_eq!(inventory[0].slot, 0);
assert_eq!(inventory[0].name, "Manifest");
assert_eq!(inventory[1].slot, 1);
assert_eq!(inventory[1].name, "Token");
assert_eq!(inventory[2].slot, 2);
assert_eq!(inventory[2].name, "Comm Log");
}
#[test]
fn occupied_slots_returns_correct_set() {
let mut world = setup_world();
let player = world.spawn_empty().id();
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
for slot in [0, 3, 7] {
world.spawn((
CarriedBy(player_sid),
ItemName("Item".into()),
InventorySlot(slot),
));
}
let mut system_state = bevy_ecs::system::SystemState::<
Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
>::new(&mut world);
let items_query = system_state.get(&world);
let mut slots = occupied_slots_for(player_sid, &items_query);
slots.sort();
assert_eq!(slots, vec![0, 3, 7]);
}
#[test]
fn max_slots_is_nine() {
assert_eq!(MAX_INVENTORY_SLOTS, 9);
}
}
+2
View File
@@ -6,11 +6,13 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod input;
pub mod interaction;
pub mod inventory;
pub mod monologue;
pub mod movement;
pub mod path_follow;
pub mod pathfinding;
pub mod rng;
pub mod stance;
pub mod tier;
pub mod time;
+426
View File
@@ -3,6 +3,10 @@
// Selects monologue lines from loaded content pools based on trigger conditions.
// v0.1: enter_location (on first tick) + time_idle (periodic when player hasn't moved).
// Lines are written to MonologueBuffer for inclusion in ObserverSnapshot.
//
// Sprint anomaly monologue (#428, D-055):
// When sprinting past a Contradicted entity, a delayed "double-take" monologue
// fires retroactively. Detection in observer pipeline, processing here.
use bevy_ecs::prelude::*;
use rand::Rng;
@@ -24,6 +28,19 @@ const IDLE_THRESHOLD_TICKS: u64 = 100;
/// Display duration for monologue text on client (seconds).
const DISPLAY_DURATION: f32 = 5.0;
/// Tick delay before a sprint anomaly monologue fires (#428, D-055).
/// At ~60 ticks/second (60fps Full rate), 90 ticks ≈ 1.5 real seconds.
/// Tunable: adjust based on actual client frame rate.
pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
/// Future: move to content pools with trigger="sprint_anomaly".
const ANOMALY_LINES: &[(&str, &str)] = &[
("sprint_anomaly_01", "Wait \u{2014} something wasn't right back there."),
("sprint_anomaly_02", "Hold on. That face... why were they there?"),
("sprint_anomaly_03", "Something's off. That wasn't where they should be."),
];
/// Tracks monologue state for cooldown and trigger detection.
/// Attached to the PlayerCharacter entity.
#[derive(Component, Debug)]
@@ -70,6 +87,101 @@ impl MonologueBuffer {
}
}
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
///
/// When sprinting past a Contradicted entity, the observer pipeline detects
/// the anomaly and pushes it here. After ANOMALY_DELAY_TICKS, the processing
/// system fires a retroactive monologue ("Wait — was that...?").
///
/// At most one anomaly is pending at a time (first-in wins).
#[derive(Component, Debug, Default)]
pub struct SprintAnomalyQueue {
pending: Option<SprintAnomalyEntry>,
}
#[derive(Debug, Clone)]
struct SprintAnomalyEntry {
entity_id: u64,
detected_tick: u64,
}
impl SprintAnomalyQueue {
/// Queue an anomaly if none is pending.
/// First-in wins: subsequent anomalies are ignored until the current one fires.
pub fn push_anomaly(&mut self, entity_id: u64, tick: u64) {
if self.pending.is_none() {
self.pending = Some(SprintAnomalyEntry {
entity_id,
detected_tick: tick,
});
}
}
/// Take the pending anomaly if the delay has elapsed.
/// Returns the entity_id that triggered the anomaly.
pub fn take_ready(&mut self, current_tick: u64) -> Option<u64> {
if let Some(entry) = &self.pending {
if current_tick.saturating_sub(entry.detected_tick) >= ANOMALY_DELAY_TICKS {
let entity_id = entry.entity_id;
self.pending = None;
return Some(entity_id);
}
}
None
}
/// Whether an anomaly is pending (detected but not yet fired).
pub fn has_pending(&self) -> bool {
self.pending.is_some()
}
}
/// Process delayed sprint anomaly monologues (#428, D-055).
///
/// Checks SprintAnomalyQueue for entries past the delay threshold and fires
/// a "double-take" monologue. Bypasses normal monologue cooldown since sprint
/// anomalies are event-driven, not periodic. Updates last_fired_tick so
/// subsequent normal monologue respects cooldown after the anomaly fires.
///
/// System ordering: after trigger_monologue, before compute_observer_snapshot.
pub fn process_sprint_anomaly_monologue(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
mut query: Query<
(&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState),
With<PlayerCharacter>,
>,
) {
let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else {
return;
};
// Don't override existing monologue from trigger_monologue
if buffer.event.is_some() {
return;
}
if let Some(_entity_id) = queue.take_ready(time.tick) {
let index = rng.rng.random_range(0..ANOMALY_LINES.len());
let (id, text) = ANOMALY_LINES[index];
buffer.event = Some(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: DISPLAY_DURATION,
});
// Update last_fired_tick so normal monologue respects cooldown
state.last_fired_tick = time.tick;
tracing::debug!(
"Sprint anomaly monologue fired: id={}, tick={}",
id,
time.tick
);
}
}
/// Monologue trigger system.
///
/// Runs each tick. Checks trigger conditions against loaded content pools
@@ -318,4 +430,318 @@ mod tests {
let event = buffer.event.as_ref().unwrap();
assert_eq!(event.id, "test_idle_001");
}
// -----------------------------------------------------------------------
// SprintAnomalyQueue unit tests (#428, D-055)
// -----------------------------------------------------------------------
#[test]
fn anomaly_queue_default_is_empty() {
let queue = SprintAnomalyQueue::default();
assert!(!queue.has_pending());
}
#[test]
fn anomaly_queue_push_stores_entry() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
assert!(queue.has_pending());
}
#[test]
fn anomaly_queue_first_in_wins() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
queue.push_anomaly(99, 101); // Should be ignored
assert!(queue.has_pending());
// The first anomaly (entity 42) should be the one that fires
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(42));
}
#[test]
fn anomaly_queue_take_ready_before_delay() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
// Not enough delay yet
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS - 1);
assert_eq!(result, None);
assert!(queue.has_pending()); // Still pending
}
#[test]
fn anomaly_queue_take_ready_at_delay() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
// Exactly at delay threshold
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(42));
assert!(!queue.has_pending()); // Consumed
}
#[test]
fn anomaly_queue_take_ready_clears_entry() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
// Second take should return None
let result = queue.take_ready(100 + ANOMALY_DELAY_TICKS + 10);
assert_eq!(result, None);
}
#[test]
fn anomaly_queue_can_push_after_take() {
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 100);
let _ = queue.take_ready(100 + ANOMALY_DELAY_TICKS);
assert!(!queue.has_pending());
// Push a new anomaly after the first was consumed
queue.push_anomaly(99, 300);
assert!(queue.has_pending());
let result = queue.take_ready(300 + ANOMALY_DELAY_TICKS);
assert_eq!(result, Some(99));
}
// -----------------------------------------------------------------------
// process_sprint_anomaly_monologue system tests (#428, D-055)
// -----------------------------------------------------------------------
fn setup_anomaly_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world
}
#[test]
fn anomaly_monologue_fires_after_delay() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0); // Queued at tick 0
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
// Advance past delay
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_some());
let event = buffer.event.as_ref().unwrap();
assert!(event.id.starts_with("sprint_anomaly_"));
}
#[test]
fn anomaly_monologue_not_before_delay() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
// Still within delay
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert!(buffer.event.is_none());
}
#[test]
fn anomaly_monologue_does_not_override_existing() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
// Pre-fill the monologue buffer (as if trigger_monologue already wrote)
let mut buffer = MonologueBuffer::default();
buffer.event = Some(MonologueEvent {
id: "existing_line".to_string(),
text: "I should keep this.".to_string(),
duration_seconds: 5.0,
});
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
buffer,
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
// Should still have the original line, not the anomaly line
let mut query = world.query::<&MonologueBuffer>();
let buffer = query.single(&world).unwrap();
assert_eq!(buffer.event.as_ref().unwrap().id, "existing_line");
// Queue should still be pending (not consumed)
let mut q = world.query::<&SprintAnomalyQueue>();
assert!(q.single(&world).unwrap().has_pending());
}
#[test]
fn anomaly_monologue_updates_last_fired_tick() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&MonologueState>();
let state = query.single(&world).unwrap();
assert_eq!(state.last_fired_tick, ANOMALY_DELAY_TICKS);
}
#[test]
fn anomaly_monologue_clears_queue_after_fire() {
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
schedule.run(&mut world);
let mut query = world.query::<&SprintAnomalyQueue>();
let queue = query.single(&world).unwrap();
assert!(!queue.has_pending());
}
#[test]
fn anomaly_monologue_no_crash_without_queue() {
// Backward compat: entities without SprintAnomalyQueue don't crash
let mut world = setup_anomaly_world();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
// Should not panic
schedule.run(&mut world);
}
#[test]
fn anomaly_delay_constant_is_90_ticks() {
// D-055 spec: ~1.5 real seconds at 60fps → 90 ticks
assert_eq!(ANOMALY_DELAY_TICKS, 90);
}
#[test]
fn anomaly_lines_all_valid() {
// All hardcoded v0.1 lines should have id prefix and non-empty text
assert!(!ANOMALY_LINES.is_empty());
for (id, text) in ANOMALY_LINES {
assert!(id.starts_with("sprint_anomaly_"), "id={} should start with sprint_anomaly_", id);
assert!(!text.is_empty(), "text for {} should be non-empty", id);
}
}
#[test]
fn anomaly_full_cycle_detect_then_fire() {
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90
let mut world = setup_anomaly_world();
let mut queue = SprintAnomalyQueue::default();
queue.push_anomaly(42, 0);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
queue,
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_sprint_anomaly_monologue);
// Tick 89: still within delay — should NOT fire
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS - 1;
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
assert!(buf_query.single(&world).unwrap().event.is_none(), "should not fire before delay");
let mut q_query = world.query::<&SprintAnomalyQueue>();
assert!(q_query.single(&world).unwrap().has_pending(), "still pending before delay");
// Tick 90: delay elapsed — should fire
world.resource_mut::<SimulationTime>().tick = ANOMALY_DELAY_TICKS;
schedule.run(&mut world);
let mut buf_query = world.query::<&MonologueBuffer>();
let buffer = buf_query.single(&world).unwrap();
assert!(buffer.event.is_some(), "should fire at delay threshold");
let event = buffer.event.as_ref().unwrap();
assert!(event.id.starts_with("sprint_anomaly_"));
assert_eq!(event.duration_seconds, DISPLAY_DURATION);
// Queue should be cleared
let mut q_query = world.query::<&SprintAnomalyQueue>();
assert!(!q_query.single(&world).unwrap().has_pending(), "queue cleared after fire");
// last_fired_tick should be updated
let mut state_query = world.query::<&MonologueState>();
assert_eq!(
state_query.single(&world).unwrap().last_fired_tick,
ANOMALY_DELAY_TICKS,
"last_fired_tick updated for cooldown"
);
}
}
+353 -20
View File
@@ -1,5 +1,6 @@
// Tile-based movement and collision system
// Implements Sprint 1 ticket #236: walkability map and movement validation
// Extended by #420: TilePresence posture layers for same-tile occupancy (D-054)
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
// Y-down convention: North = y-1, South = y+1
@@ -14,6 +15,27 @@ pub const CHUNK_SIZE: i32 = 32;
#[derive(Component, Debug)]
pub struct PlayerCharacter;
/// Posture layer for same-tile occupancy (D-054, #420).
///
/// Multiple entities can share a tile if they occupy different posture layers.
/// Two entities in the same layer on the same tile is a collision.
///
/// Examples: a Standing character can walk past a Seated NPC at a console,
/// a Fixture (terminal) shares a tile with someone Seated at it.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum TilePresence {
/// Upright position — walking, standing, sprinting. Default for all entities.
#[default]
Standing,
/// Low position — crouching or prone on the ground.
Prone,
/// Seated at furniture, console, or vehicle.
Seated,
/// Immovable world fixture — terminals, furniture, consoles.
/// Occupies its layer permanently.
Fixture,
}
/// Tile position component for grid-based movement.
/// Discrete integer coordinates used in simulation; converted to f32
/// at the bridge boundary for VisibleEntity wire format.
@@ -226,48 +248,59 @@ pub struct MoveIntent {
}
/// System to validate and execute movement intents.
/// Checks walkability map AND entity-entity collision before allowing moves.
/// Processes all intents in a single pass: first collect occupied tiles from
/// Checks walkability map AND layer-based entity collision before allowing moves.
///
/// Same-tile occupancy (D-054, #420): multiple entities can share a tile if they
/// occupy different posture layers (TilePresence). Two entities in the same layer
/// on the same tile is a collision. Entities without TilePresence default to Standing.
///
/// Processes all intents in a single pass: first collect occupied layer slots from
/// entities without intents, then resolve movers in order — first valid claim
/// to a tile wins.
/// to a layer slot wins.
/// Always removes MoveIntent component after processing.
pub fn validate_movement(
mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
stationary: Query<(Entity, &TilePosition), Without<MoveIntent>>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>,
stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without<MoveIntent>>,
) {
let Some(map) = walkability else {
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
for (entity, _, _) in movers.iter() {
for (entity, _, _, _) in movers.iter() {
commands.entity(entity).remove::<MoveIntent>();
}
return;
};
// Collect tiles occupied by stationary entities (no MoveIntent)
let mut occupied: HashMap<TilePosition, Entity> = HashMap::new();
for (entity, pos) in stationary.iter() {
occupied.insert(*pos, entity);
// Collect layer slots occupied by stationary entities (no MoveIntent).
// Key: (position, layer) — two entities can share a tile if different layers.
let mut occupied: HashMap<(TilePosition, TilePresence), Entity> = HashMap::new();
for (entity, pos, presence) in stationary.iter() {
let layer = presence.copied().unwrap_or_default();
occupied.insert((*pos, layer), entity);
}
for (entity, intent, mut position) in movers.iter_mut() {
for (entity, intent, mut position, presence) in movers.iter_mut() {
let target = &intent.target;
let layer = presence.copied().unwrap_or_default();
let slot = (*target, layer);
if !map.can_move_to(target) {
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
} else if occupied.contains_key(target) {
tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target);
} else if occupied.contains_key(&slot) {
tracing::trace!(
"Entity {:?} blocked by entity at {:?} (layer {:?})",
entity, target, layer
);
} else {
tracing::trace!(
"Entity {:?} moving from {:?} to {:?}",
entity,
*position,
target
"Entity {:?} moving from {:?} to {:?} (layer {:?})",
entity, *position, target, layer
);
// Free old tile, claim new tile
occupied.remove(&*position);
// Free old layer slot, claim new one
occupied.remove(&(*position, layer));
*position = *target;
occupied.insert(*target, entity);
occupied.insert(slot, entity);
}
commands.entity(entity).remove::<MoveIntent>();
}
@@ -570,4 +603,304 @@ mod tests {
);
assert!(world.get::<MoveIntent>(entity).is_none());
}
// -----------------------------------------------------------------------
// TilePresence / same-tile occupancy tests (D-054, #420)
// -----------------------------------------------------------------------
#[test]
fn tile_presence_default_is_standing() {
assert_eq!(TilePresence::default(), TilePresence::Standing);
}
#[test]
fn same_layer_same_tile_blocks_movement() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity at target, Standing layer
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Mover also Standing — should be blocked
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"same-layer collision should block movement"
);
}
#[test]
fn different_layer_same_tile_allows_movement() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Fixture at target tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Standing mover — different layer, should pass
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 4, 0),
"different layers should share a tile"
);
}
#[test]
fn seated_and_fixture_share_tile() {
// Common case: NPC seated at a terminal (Fixture)
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Terminal fixture at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Seated NPC moves to same tile
let npc = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Seated,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(npc).unwrap(),
TilePosition::new(5, 4, 0),
"Seated NPC should share tile with Fixture"
);
}
#[test]
fn prone_and_standing_share_tile() {
// Eavesdrop scenario: prone entity next to standing entity
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Standing NPC at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Prone entity moves in — different layer
let prone = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Prone,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(prone).unwrap(),
TilePosition::new(5, 4, 0),
"Prone should share tile with Standing"
);
}
#[test]
fn entity_without_tile_presence_defaults_to_standing() {
// Backwards compat: entities spawned without TilePresence should
// still collide with Standing entities (default layer).
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity WITHOUT TilePresence component
world.spawn(TilePosition::new(5, 4, 0));
// Mover also WITHOUT TilePresence — both default to Standing
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"entities without TilePresence should default to Standing and collide"
);
}
#[test]
fn entity_without_presence_blocked_by_standing() {
// Entity without TilePresence blocked by explicit Standing entity
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary with explicit Standing
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Standing));
// Mover without TilePresence (defaults to Standing)
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"no-presence entity should collide with Standing"
);
}
#[test]
fn three_layers_on_same_tile() {
// Maximum plausible scenario: Standing + Seated + Fixture on one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Fixture already at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
// Seated already at tile
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Seated));
// Standing mover enters — third layer
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 4, 0),
"three different layers should coexist on one tile"
);
}
#[test]
fn two_fixtures_same_tile_blocked() {
// Edge case: two fixtures can't stack on the same tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
world.spawn((TilePosition::new(5, 4, 0), TilePresence::Fixture));
let mover = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Fixture,
MoveIntent {
target: TilePosition::new(5, 4, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0),
"two Fixtures on same tile should collide"
);
}
#[test]
fn all_four_layers_coexist_on_same_tile() {
// D-054: Standing + Prone + Seated + Fixture all share one tile
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let target = TilePosition::new(5, 4, 0);
// Fixture and Prone already at tile
world.spawn((target, TilePresence::Fixture));
world.spawn((target, TilePresence::Prone));
// Standing mover enters
let standing = world
.spawn((
TilePosition::new(5, 5, 0),
TilePresence::Standing,
MoveIntent { target },
))
.id();
// Seated mover enters from elsewhere
let seated = world
.spawn((
TilePosition::new(5, 3, 0),
TilePresence::Seated,
MoveIntent { target },
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
assert_eq!(
*world.get::<TilePosition>(standing).unwrap(),
target,
"Standing should share tile with Fixture + Prone"
);
assert_eq!(
*world.get::<TilePosition>(seated).unwrap(),
target,
"Seated should share tile with Fixture + Prone + Standing"
);
}
}
+250
View File
@@ -0,0 +1,250 @@
// Stance system — D-053 movement stances with tick-based speed
//
// MovementStance (Sprint/Walk/Careful/Crouch) affects:
// - Movement speed (ticks per step): Sprint=1, Walk=2, Careful=3, Crouch=4
// - Monologue rate: Sprint=40%, Walk=100%, Careful=150%, Crouch=100%
// - Interaction buffer: Sprint suppresses (D-055, wired in #419)
//
// The stance ladder is toggled via PlayerAction::ToggleStanceUp/Down.
// This module provides the ECS component and movement cooldown.
use bevy_ecs::prelude::*;
use crate::bridge::types::MovementStance;
/// Per-archetype default movement configuration (D-053).
/// Stores the default stance so spawn code can initialize Stance from it.
///
/// v0.1: smuggler and detective both default to Walk.
/// Future archetypes may differ (e.g., maintenance worker → Careful).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MovementProfile {
pub default_stance: MovementStance,
}
impl Default for MovementProfile {
fn default() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
}
impl MovementProfile {
pub fn smuggler() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
pub fn detective() -> Self {
Self {
default_stance: MovementStance::Walk,
}
}
/// Create the initial Stance component from this profile's default.
pub fn initial_stance(&self) -> Stance {
Stance(self.default_stance)
}
}
/// ECS component tracking an entity's current movement stance.
/// Attached to PlayerCharacter (and potentially NPCs in future).
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Stance(pub MovementStance);
impl Default for Stance {
fn default() -> Self {
Stance(MovementStance::Walk)
}
}
/// Tracks ticks since last movement step for stance-based speed enforcement.
/// The player's movement is throttled server-side based on their current stance.
#[derive(Component, Debug, Clone)]
pub struct PlayerMoveCooldown {
pub ticks_since_last_move: u32,
}
impl Default for PlayerMoveCooldown {
fn default() -> Self {
Self {
// Start at max so first move is immediate
ticks_since_last_move: u32::MAX,
}
}
}
impl PlayerMoveCooldown {
/// Check if the player can move this tick given their stance.
/// Returns true and resets the counter if movement is allowed.
pub fn try_move(&mut self, stance: MovementStance) -> bool {
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
if self.ticks_since_last_move >= stance.ticks_per_move() {
self.ticks_since_last_move = 0;
true
} else {
false
}
}
/// Advance the cooldown counter without attempting a move.
/// Call this each tick when no move input is present to keep the counter progressing.
pub fn tick(&mut self) {
self.ticks_since_last_move = self.ticks_since_last_move.saturating_add(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stance_default_is_walk() {
assert_eq!(Stance::default().0, MovementStance::Walk);
}
#[test]
fn stance_ladder_step_up() {
assert_eq!(MovementStance::Crouch.step_up(), MovementStance::Careful);
assert_eq!(MovementStance::Careful.step_up(), MovementStance::Walk);
assert_eq!(MovementStance::Walk.step_up(), MovementStance::Sprint);
assert_eq!(MovementStance::Sprint.step_up(), MovementStance::Sprint);
}
#[test]
fn stance_ladder_step_down() {
assert_eq!(MovementStance::Sprint.step_down(), MovementStance::Walk);
assert_eq!(MovementStance::Walk.step_down(), MovementStance::Careful);
assert_eq!(MovementStance::Careful.step_down(), MovementStance::Crouch);
assert_eq!(MovementStance::Crouch.step_down(), MovementStance::Crouch);
}
#[test]
fn ticks_per_move_values() {
assert_eq!(MovementStance::Sprint.ticks_per_move(), 1);
assert_eq!(MovementStance::Walk.ticks_per_move(), 2);
assert_eq!(MovementStance::Careful.ticks_per_move(), 3);
assert_eq!(MovementStance::Crouch.ticks_per_move(), 4);
}
#[test]
fn monologue_rate_values() {
assert_eq!(MovementStance::Sprint.monologue_rate_percent(), 40);
assert_eq!(MovementStance::Walk.monologue_rate_percent(), 100);
assert_eq!(MovementStance::Careful.monologue_rate_percent(), 150);
assert_eq!(MovementStance::Crouch.monologue_rate_percent(), 100);
}
#[test]
fn cooldown_first_move_immediate() {
let mut cd = PlayerMoveCooldown::default();
// First move should always succeed (counter starts at MAX)
assert!(cd.try_move(MovementStance::Walk));
}
#[test]
fn cooldown_sprint_every_tick() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Sprint)); // tick 1
assert!(cd.try_move(MovementStance::Sprint)); // tick 2
assert!(cd.try_move(MovementStance::Sprint)); // tick 3
}
#[test]
fn cooldown_walk_every_two_ticks() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Walk)); // tick 1: allowed (first)
assert!(!cd.try_move(MovementStance::Walk)); // tick 2: cooldown
assert!(cd.try_move(MovementStance::Walk)); // tick 3: allowed
assert!(!cd.try_move(MovementStance::Walk)); // tick 4: cooldown
assert!(cd.try_move(MovementStance::Walk)); // tick 5: allowed
}
#[test]
fn cooldown_careful_every_three_ticks() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Careful)); // tick 1: allowed (first)
assert!(!cd.try_move(MovementStance::Careful)); // tick 2: cd
assert!(!cd.try_move(MovementStance::Careful)); // tick 3: cd
assert!(cd.try_move(MovementStance::Careful)); // tick 4: allowed
}
#[test]
fn cooldown_crouch_every_four_ticks() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Crouch)); // tick 1: allowed (first)
assert!(!cd.try_move(MovementStance::Crouch)); // tick 2: cd
assert!(!cd.try_move(MovementStance::Crouch)); // tick 3: cd
assert!(!cd.try_move(MovementStance::Crouch)); // tick 4: cd
assert!(cd.try_move(MovementStance::Crouch)); // tick 5: allowed
}
#[test]
fn cooldown_tick_advances_counter() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Walk)); // move
cd.tick(); // no move, but counter advances
assert!(cd.try_move(MovementStance::Walk)); // allowed after tick + try_move = 2
}
#[test]
fn cooldown_stance_switch_mid_cooldown() {
let mut cd = PlayerMoveCooldown::default();
assert!(cd.try_move(MovementStance::Crouch)); // move at crouch speed
// Switch to sprint mid-cooldown
assert!(cd.try_move(MovementStance::Sprint)); // sprint allows every tick
}
// -----------------------------------------------------------------------
// MovementProfile tests (#418, D-053)
// -----------------------------------------------------------------------
#[test]
fn movement_profile_default_is_walk() {
let profile = MovementProfile::default();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_smuggler_defaults_to_walk() {
let profile = MovementProfile::smuggler();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_detective_defaults_to_walk() {
let profile = MovementProfile::detective();
assert_eq!(profile.default_stance, MovementStance::Walk);
}
#[test]
fn movement_profile_initial_stance_matches_default() {
let profile = MovementProfile::smuggler();
let stance = profile.initial_stance();
assert_eq!(stance.0, profile.default_stance);
}
#[test]
fn movement_profile_custom_default_stance() {
let profile = MovementProfile {
default_stance: MovementStance::Careful,
};
assert_eq!(profile.default_stance, MovementStance::Careful);
assert_eq!(profile.initial_stance().0, MovementStance::Careful);
}
#[test]
fn movement_profile_as_ecs_component() {
let mut world = bevy_ecs::world::World::new();
let profile = MovementProfile::smuggler();
let entity = world.spawn((profile, profile.initial_stance(), PlayerMoveCooldown::default())).id();
let stored = world.get::<MovementProfile>(entity).unwrap();
assert_eq!(stored.default_stance, MovementStance::Walk);
let stance = world.get::<Stance>(entity).unwrap();
assert_eq!(stance.0, MovementStance::Walk);
}
}
+2
View File
@@ -42,6 +42,8 @@ fn snapshot_roundtrip_over_unix_socket() {
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![VisibleEntity {
entity_id: 100,
x: 10.5,
+2
View File
@@ -28,6 +28,8 @@ fn snapshot_roundtrip_over_tcp() {
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![VisibleEntity {
entity_id: 100,
x: 10.5,
+1 -1
View File
@@ -66,7 +66,7 @@ fn player_moves_north_through_full_pipeline() {
rmp_serde::from_slice(&response).expect("deserialize snapshot");
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
assert_eq!(snapshot.version, 5);
assert_eq!(snapshot.version, 6);
assert_eq!(snapshot.tick, 0);
assert_eq!(snapshot.entities.len(), 1);
+4
View File
@@ -27,6 +27,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities,
visible_tiles: vec![],
nearby_interactions: vec![],
@@ -161,6 +163,8 @@ fn generate_msgpack_fixtures() {
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::Southeast,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![VisibleEntity {
entity_id: 1,
x: 10.5,
+304 -1
View File
@@ -16,6 +16,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities,
visible_tiles: vec![],
nearby_interactions: vec![],
@@ -88,6 +90,9 @@ fn all_player_action_variants_roundtrip() {
PlayerAction::UsePerceptionMode("thermal".to_string()),
PlayerAction::Pause,
PlayerAction::Unpause,
PlayerAction::SetTickRate(TickRate::Half),
PlayerAction::ToggleStanceUp,
PlayerAction::ToggleStanceDown,
];
for action in actions {
@@ -190,6 +195,8 @@ fn snapshot_v2_fields_roundtrip() {
tick_rate: TickRate::Paused,
},
player_facing: FacingDirection::Southeast,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![VisibleEntity {
entity_id: 1,
x: 5.5,
@@ -261,7 +268,7 @@ fn entity_to_bits_roundtrip() {
fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(PROTOCOL_VERSION, 5, "bump this assertion when protocol version changes");
assert_eq!(PROTOCOL_VERSION, 6, "bump this assertion when protocol version changes");
}
/// All FacingDirection variants round-trip
@@ -289,6 +296,8 @@ fn all_facing_direction_variants_roundtrip() {
tick_rate: TickRate::Full,
},
player_facing: dir,
player_stance: MovementStance::default(),
player_inventory: vec![],
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
@@ -299,3 +308,297 @@ fn all_facing_direction_variants_roundtrip() {
assert_eq!(decoded.player_facing, dir);
}
}
/// v6 fields: all MovementStance variants round-trip (#449, D-053)
#[test]
fn all_movement_stance_variants_roundtrip() {
let stances = [
MovementStance::Sprint,
MovementStance::Walk,
MovementStance::Careful,
MovementStance::Crouch,
];
for stance in stances {
let snapshot = test_snapshot(0, vec![]);
let mut snapshot = snapshot;
snapshot.player_stance = stance;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_stance, stance);
}
}
/// v6 fields: player_inventory with items round-trips (#449, D-065)
#[test]
fn snapshot_v6_inventory_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.player_stance = MovementStance::Careful;
snapshot.player_inventory = vec![
InventoryItem {
item_id: 100,
name: "Manifest Copy".into(),
slot: 0,
},
InventoryItem {
item_id: 101,
name: "Access Token".into(),
slot: 1,
},
InventoryItem {
item_id: 102,
name: "Comm Log".into(),
slot: 2,
},
];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_stance, MovementStance::Careful);
assert_eq!(decoded.player_inventory.len(), 3);
assert_eq!(decoded.player_inventory[0].item_id, 100);
assert_eq!(decoded.player_inventory[0].name, "Manifest Copy");
assert_eq!(decoded.player_inventory[0].slot, 0);
assert_eq!(decoded.player_inventory[2].name, "Comm Log");
assert_eq!(decoded.player_inventory[2].slot, 2);
}
/// v6 fields: default stance is Walk, default inventory is empty (#449)
#[test]
fn snapshot_v6_defaults() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.player_stance, MovementStance::Walk);
assert!(snapshot.player_inventory.is_empty());
}
/// v5 payloads (without player_stance/player_inventory) must deserialize into
/// the v6 struct via #[serde(default)]. Guards backwards compat during migration.
#[test]
fn v5_payload_deserializes_into_v6_struct() {
// Local v5 struct: ObserverSnapshot without player_stance and player_inventory
#[derive(serde::Serialize)]
struct ObserverSnapshotV5 {
version: u8,
tick: u64,
game_time: GameTime,
player_facing: FacingDirection,
entities: Vec<VisibleEntity>,
visible_tiles: Vec<VisibleTile>,
nearby_interactions: Vec<NearbyInteraction>,
current_monologue: Option<MonologueEvent>,
}
let v5 = ObserverSnapshotV5 {
version: 5,
tick: 42,
game_time: GameTime {
day: 0,
time_of_day: 0,
day_phase: DayPhase::Morning,
tick_rate: TickRate::Full,
},
player_facing: FacingDirection::North,
entities: vec![],
visible_tiles: vec![],
nearby_interactions: vec![],
current_monologue: None,
};
let bytes = rmp_serde::to_vec_named(&v5).expect("serialize v5");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v5 payload should deserialize into v6 struct via serde(default)");
// New fields should get their defaults
assert_eq!(decoded.version, 5, "version field preserved from v5");
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.player_stance, MovementStance::Walk, "missing stance should default to Walk");
assert!(decoded.player_inventory.is_empty(), "missing inventory should default to empty");
assert!(decoded.current_monologue.is_none(), "missing monologue should default to None");
}
/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal)
#[test]
fn snapshot_v6_full_inventory_roundtrip() {
let items: Vec<InventoryItem> = (0..9).map(|i| InventoryItem {
item_id: 100 + i as u64,
name: format!("Item {}", i),
slot: i,
}).collect();
let mut snapshot = test_snapshot(0, vec![]);
snapshot.player_inventory = items;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.player_inventory.len(), 9);
for (i, item) in decoded.player_inventory.iter().enumerate() {
assert_eq!(item.slot, i as u8, "slot {} should match index", i);
assert_eq!(item.item_id, 100 + i as u64);
}
// Slot 8 is max valid (0-indexed, 3x3 grid)
assert_eq!(decoded.player_inventory[8].slot, 8);
}
/// All VerbKind variants must survive MessagePack round-trip (#421, D-057).
/// Guards against serde mapping breakage when new verbs are added.
#[test]
fn all_verb_kind_variants_roundtrip() {
let all_verbs = [
(VerbKind::ExamineNpc, "Observe"),
(VerbKind::Talk, "Talk"),
(VerbKind::Observe, "Observe"),
(VerbKind::Read, "Read"),
(VerbKind::Open, "Open"),
(VerbKind::Close, "Close"),
(VerbKind::Search, "Search"),
(VerbKind::Use, "Use"),
(VerbKind::Take, "Take"),
(VerbKind::Sit, "Sit"),
(VerbKind::Confront, "Confront"),
(VerbKind::ExamineObject, "Examine"),
];
for (kind, label) in all_verbs {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Object,
distance: 1,
verbs: vec![VerbOption {
kind,
label: label.into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions.len(), 1);
assert_eq!(
decoded.nearby_interactions[0].verbs[0].kind, kind,
"VerbKind::{:?} did not roundtrip", kind
);
}
}
/// ObjectType enum round-trips through MessagePack (#421).
/// While not on the wire in ObserverSnapshot, ObjectType has Serialize/Deserialize
/// for future save/load and must round-trip cleanly.
#[test]
fn all_object_type_variants_roundtrip() {
use settled_reach_server::simulation::interaction::ObjectType;
let types = [
ObjectType::Readable,
ObjectType::Container,
ObjectType::Terminal,
ObjectType::Door,
ObjectType::Pickup,
ObjectType::Furniture,
];
for obj_type in types {
let bytes = rmp_serde::to_vec_named(&obj_type).expect("serialize");
let decoded: ObjectType = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, obj_type, "ObjectType::{:?} roundtrip failed", obj_type);
}
}
/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip.
/// Guards against Confront being omitted from serde mapping.
#[test]
fn verb_kind_confront_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Confront,
label: "Confront".into(),
priority: 3,
available: true,
}],
object_type: None,
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions.len(), 1);
assert_eq!(decoded.nearby_interactions[0].verbs[0].kind, VerbKind::Confront);
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
}
/// CharacterArchetype enum round-trips through MessagePack (#422).
/// Used in Phase 2 label relabeling — must survive the wire.
#[test]
fn all_character_archetype_variants_roundtrip() {
let archetypes = [
CharacterArchetype::Smuggler,
CharacterArchetype::Detective,
];
for archetype in archetypes {
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, archetype, "CharacterArchetype::{:?} roundtrip failed", archetype);
}
}
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
/// Guards the contradiction flag survives serialization.
#[test]
fn nearby_interaction_contradicted_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Npc,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Talk,
label: "Talk".into(),
priority: 1,
available: true,
}],
object_type: None,
contradicted: true,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert!(decoded.nearby_interactions[0].contradicted, "contradicted flag should survive roundtrip");
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
fn nearby_interaction_object_type_roundtrip() {
let mut snapshot = test_snapshot(0, vec![]);
snapshot.nearby_interactions = vec![NearbyInteraction {
entity_id: 1,
entity_type: EntityKind::Object,
distance: 1,
verbs: vec![VerbOption {
kind: VerbKind::Open,
label: "Open".into(),
priority: 1,
available: true,
}],
object_type: Some(ObjectType::Container),
contradicted: false,
}];
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.nearby_interactions[0].object_type, Some(ObjectType::Container));
}