diff --git a/.claude/settings.json b/.claude/settings.json index ec5ce99e9..b1d7d9b97 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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 *)" + ] + } } diff --git a/.claude/skills/plan-sprint/SKILL.md b/.claude/skills/plan-sprint/SKILL.md index 7d9cbe43e..2c2afdf85 100644 --- a/.claude/skills/plan-sprint/SKILL.md +++ b/.claude/skills/plan-sprint/SKILL.md @@ -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 --status in_progress -db/connectors/ticket list --sprint --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 @@ -88,7 +78,7 @@ db/connectors/ticket children Use `db/connectors/ticket show --brief [...]` 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 ``` -### 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 diff --git a/.claude/skills/start-sprint/SKILL.md b/.claude/skills/start-sprint/SKILL.md index d0df783a5..3999e040d 100644 --- a/.claude/skills/start-sprint/SKILL.md +++ b/.claude/skills/start-sprint/SKILL.md @@ -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/.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 ``` -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 diff --git a/.claude/skills/start-workshop/SKILL.md b/.claude/skills/start-workshop/SKILL.md new file mode 100644 index 000000000..40f11cb5e --- /dev/null +++ b/.claude/skills/start-workshop/SKILL.md @@ -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 | diff --git a/.claude/skills/ticket/SKILL.md b/.claude/skills/ticket/SKILL.md index e5ef809a3..bb796e8ae 100644 --- a/.claude/skills/ticket/SKILL.md +++ b/.claude/skills/ticket/SKILL.md @@ -53,6 +53,9 @@ db/connectors/ticket sprint [--active] db/connectors/ticket sprint assign ``` +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 diff --git a/.claude/skills/worktree-update/SKILL.md b/.claude/skills/worktree-update/SKILL.md index 403b2e1c3..d0a676748 100644 --- a/.claude/skills/worktree-update/SKILL.md +++ b/.claude/skills/worktree-update/SKILL.md @@ -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.. +git rev-list --count main..origin/ ``` Skip branches with 0 commits ahead. For branches that ARE ahead, collect: diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0620062..06d62bd1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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\) 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 diff --git a/CLAUDE.md b/CLAUDE.md index a8ee610a2..364a9866e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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'" diff --git a/client/project.godot b/client/project.godot index f72f90543..1180aa656 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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] diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 0a826303e..04770f248 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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 diff --git a/client/scripts/autoloads/fog_state.gd b/client/scripts/autoloads/fog_state.gd new file mode 100644 index 000000000..d63acd96e --- /dev/null +++ b/client/scripts/autoloads/fog_state.gd @@ -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 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) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 62333e36b..a453c7030 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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: diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 6ec767791..c02e0e19e 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -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: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 2e6d885b5..ac450d448 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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(), diff --git a/client/scripts/autoloads/ui_strings.gd.uid b/client/scripts/autoloads/ui_strings.gd.uid new file mode 100644 index 000000000..f254846f4 --- /dev/null +++ b/client/scripts/autoloads/ui_strings.gd.uid @@ -0,0 +1 @@ +uid://xm2hswjm3pss diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index f7df9800f..82fdaac05 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -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 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 3feca31a5..2ef4e2463 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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"] = { diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index dc2be42d4..ffeb5a0aa 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -11,7 +11,7 @@ class_name Protocol ## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. ## Reject snapshots where version != this value. -const PROTOCOL_VERSION: int = 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, diff --git a/client/scripts/rendering/cursor_renderer.gd b/client/scripts/rendering/cursor_renderer.gd new file mode 100644 index 000000000..a286d2c08 --- /dev/null +++ b/client/scripts/rendering/cursor_renderer.gd @@ -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 diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 489c04da1..c1a9afbb0 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -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: diff --git a/client/scripts/rendering/fog_renderer.gd b/client/scripts/rendering/fog_renderer.gd deleted file mode 100644 index 41b1cd2c6..000000000 --- a/client/scripts/rendering/fog_renderer.gd +++ /dev/null @@ -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 diff --git a/client/scripts/rendering/fog_shader.gd b/client/scripts/rendering/fog_shader.gd new file mode 100644 index 000000000..2c4f1f748 --- /dev/null +++ b/client/scripts/rendering/fog_shader.gd @@ -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) diff --git a/client/scripts/rendering/world_renderer.gd b/client/scripts/rendering/world_renderer.gd index ad0c140d6..7e9594583 100644 --- a/client/scripts/rendering/world_renderer.gd +++ b/client/scripts/rendering/world_renderer.gd @@ -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"): diff --git a/client/shaders/fog.gdshader b/client/shaders/fog.gdshader new file mode 100644 index 000000000..f63af203f --- /dev/null +++ b/client/shaders/fog.gdshader @@ -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); + } +} diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 3868af268..d76779755 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 51037146a..94055d595 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 708014458..698b97852 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index e759cbf5f..a3be5bea6 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index cb6219705..2b830526b 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/client/tests/test_cursor_states.gd b/client/tests/test_cursor_states.gd new file mode 100644 index 000000000..2b5358127 --- /dev/null +++ b/client/tests/test_cursor_states.gd @@ -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() diff --git a/client/tests/test_fog_shader.gd b/client/tests/test_fog_shader.gd new file mode 100644 index 000000000..6500b5ebb --- /dev/null +++ b/client/tests/test_fog_shader.gd @@ -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 diff --git a/client/tests/test_input_roundtrip.gd.uid b/client/tests/test_input_roundtrip.gd.uid new file mode 100644 index 000000000..f2815ccdb --- /dev/null +++ b/client/tests/test_input_roundtrip.gd.uid @@ -0,0 +1 @@ +uid://st3laseti33k diff --git a/client/tests/test_interaction_list.gd b/client/tests/test_interaction_list.gd new file mode 100644 index 000000000..404d7adeb --- /dev/null +++ b/client/tests/test_interaction_list.gd @@ -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") diff --git a/client/tests/test_interaction_prompt.gd b/client/tests/test_interaction_prompt.gd index 83f7d53b8..c0237c770 100644 --- a/client/tests/test_interaction_prompt.gd +++ b/client/tests/test_interaction_prompt.gd @@ -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 -- diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index 6ac1db5ed..ea39cf760 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -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() diff --git a/client/tests/test_protocol_v6.gd b/client/tests/test_protocol_v6.gd new file mode 100644 index 000000000..53b747626 --- /dev/null +++ b/client/tests/test_protocol_v6.gd @@ -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 = [] diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 125a4ab7c..26beb5197 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -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: diff --git a/client/tests/test_ui_strings.gd.uid b/client/tests/test_ui_strings.gd.uid new file mode 100644 index 000000000..84c120016 --- /dev/null +++ b/client/tests/test_ui_strings.gd.uid @@ -0,0 +1 @@ +uid://c6vjj0dwiy80i diff --git a/client/ui/cursor_state_machine.tscn b/client/ui/cursor_state_machine.tscn new file mode 100644 index 000000000..25d6f33ad --- /dev/null +++ b/client/ui/cursor_state_machine.tscn @@ -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") diff --git a/client/ui/interaction_list.gd b/client/ui/interaction_list.gd new file mode 100644 index 000000000..8758f39d5 --- /dev/null +++ b/client/ui/interaction_list.gd @@ -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 diff --git a/client/ui/interaction_list.tscn b/client/ui/interaction_list.tscn new file mode 100644 index 000000000..056368213 --- /dev/null +++ b/client/ui/interaction_list.tscn @@ -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 diff --git a/client/ui/inventory_grid.gd b/client/ui/inventory_grid.gd new file mode 100644 index 000000000..04f28870b --- /dev/null +++ b/client/ui/inventory_grid.gd @@ -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 {} diff --git a/client/ui/inventory_grid.tscn b/client/ui/inventory_grid.tscn new file mode 100644 index 000000000..32ab8e47c --- /dev/null +++ b/client/ui/inventory_grid.tscn @@ -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") diff --git a/client/ui/stance_indicator.gd b/client/ui/stance_indicator.gd new file mode 100644 index 000000000..4c5b2de92 --- /dev/null +++ b/client/ui/stance_indicator.gd @@ -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 diff --git a/client/ui/stance_indicator.tscn b/client/ui/stance_indicator.tscn new file mode 100644 index 000000000..e53d37364 --- /dev/null +++ b/client/ui/stance_indicator.tscn @@ -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") diff --git a/client/ui/world_radial.gd b/client/ui/world_radial.gd new file mode 100644 index 000000000..2f1057f09 --- /dev/null +++ b/client/ui/world_radial.gd @@ -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 diff --git a/client/ui/world_radial.tscn b/client/ui/world_radial.tscn new file mode 100644 index 000000000..d918e33e7 --- /dev/null +++ b/client/ui/world_radial.tscn @@ -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") diff --git a/db/connectors/sprint b/db/connectors/sprint new file mode 100755 index 000000000..fb1b00997 --- /dev/null +++ b/db/connectors/sprint @@ -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 in_progress (when starting) + db/connectors/ticket status 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() diff --git a/decisions/README.md b/decisions/README.md index d2f545a90..aab0c6f15 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi | File | Domain | Decisions | |------|--------|-----------| -| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031 | -| [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 diff --git a/decisions/architecture.md b/decisions/architecture.md index 47850ea0c..8ebb9f936 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -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* diff --git a/decisions/content.md b/decisions/content.md index 92b19f4d3..40d6df98d 100644 --- a/decisions/content.md +++ b/decisions/content.md @@ -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* diff --git a/decisions/perception.md b/decisions/perception.md index b2b2265bc..847c41072 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -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* diff --git a/decisions/scope.md b/decisions/scope.md index 190eb9aa6..06257c9f4 100644 --- a/decisions/scope.md +++ b/decisions/scope.md @@ -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* diff --git a/docs/architecture/flying-taxi-analysis.md b/docs/architecture/flying-taxi-analysis.md new file mode 100644 index 000000000..9c9e7330b --- /dev/null +++ b/docs/architecture/flying-taxi-analysis.md @@ -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.* diff --git a/docs/architecture/fog-shader-spec.md b/docs/architecture/fog-shader-spec.md new file mode 100644 index 000000000..8760e5554 --- /dev/null +++ b/docs/architecture/fog-shader-spec.md @@ -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 (LOS result) + ├─ visibility_sectors: Dictionary + └─ 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. diff --git a/docs/architecture/z-layer-gap-analysis.md b/docs/architecture/z-layer-gap-analysis.md new file mode 100644 index 000000000..8d6613f18 --- /dev/null +++ b/docs/architecture/z-layer-gap-analysis.md @@ -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. diff --git a/docs/backups/settledreach.db.backup b/docs/backups/settledreach.db.backup index 814182f6c..158151ed0 100644 Binary files a/docs/backups/settledreach.db.backup and b/docs/backups/settledreach.db.backup differ diff --git a/docs/scratchpad.md b/docs/scratchpad.md new file mode 100644 index 000000000..0045f5339 --- /dev/null +++ b/docs/scratchpad.md @@ -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 + diff --git a/docs/sprints/sprint-6/client.md b/docs/sprints/sprint-6/client.md new file mode 100644 index 000000000..c22ef5625 --- /dev/null +++ b/docs/sprints/sprint-6/client.md @@ -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 ` 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: " 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 +``` diff --git a/docs/sprints/sprint-6/copy.md b/docs/sprints/sprint-6/copy.md new file mode 100644 index 000000000..e1910d3ba --- /dev/null +++ b/docs/sprints/sprint-6/copy.md @@ -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 ` 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. diff --git a/docs/sprints/sprint-6/joint.md b/docs/sprints/sprint-6/joint.md new file mode 100644 index 000000000..9ea7505c3 --- /dev/null +++ b/docs/sprints/sprint-6/joint.md @@ -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` (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. diff --git a/docs/sprints/sprint-6/server.md b/docs/sprints/sprint-6/server.md new file mode 100644 index 000000000..5c4a1625e --- /dev/null +++ b/docs/sprints/sprint-6/server.md @@ -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 ` 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` (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 +``` diff --git a/docs/workshops/README.md b/docs/workshops/README.md index 0a248dc75..b74645a1b 100644 --- a/docs/workshops/README.md +++ b/docs/workshops/README.md @@ -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 | diff --git a/docs/workshops/art-direction-mood-board/workshop-outcomes.md b/docs/workshops/art-direction-mood-board/workshop-outcomes.md index fe2723c02..66d55a253 100644 --- a/docs/workshops/art-direction-mood-board/workshop-outcomes.md +++ b/docs/workshops/art-direction-mood-board/workshop-outcomes.md @@ -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`. diff --git a/docs/workshops/control-interaction/control-interaction-workshop-brief.md b/docs/workshops/control-interaction/control-interaction-workshop-brief.md index 596d72fa6..5adbe8412 100644 --- a/docs/workshops/control-interaction/control-interaction-workshop-brief.md +++ b/docs/workshops/control-interaction/control-interaction-workshop-brief.md @@ -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 diff --git a/docs/workshops/control-interaction/workshop-notes.md b/docs/workshops/control-interaction/workshop-notes.md new file mode 100644 index 000000000..056513572 --- /dev/null +++ b/docs/workshops/control-interaction/workshop-notes.md @@ -0,0 +1,1042 @@ +# Workshop Notes: Control & Interaction Scheme — Round 1 + +**Workshop:** Control & Interaction Scheme +**Round:** 1 +**Date:** 2026-02-13 +**Documenter:** Qatux +**Participants:** Gestalt, Ozzie, Tyre, Stig, Dudley, Paula, Araminta, Nigel +**Source:** Full responses relayed via team lead (responses delivered as team messages, not written to disk) + +--- + +## Layer 1: Movement & Interaction + +### 1A. Movement Model + +#### Proposals + +**Gestalt — Walk/Sprint/Careful triad with perception consequences:** +- Walk: default speed. Normal monologue rate, normal footstep volume. +- Sprint: 1.8x speed. Monologue triggers at 40% rate (character stops *thinking*, not seeing). Loud footsteps (D-018 implications). Sprint suppresses interaction computation (confirmed by Dudley). +- Careful: 0.5x speed. Monologue triggers at 150% rate, "tell notice bonus" (enhanced observation), quiet footsteps. +- **NO vision cone change** for any speed mode. Perception change is cognitive (monologue rate), not sensory (cone size). +- Emergent surveillance pattern: sprint ahead to get position → careful to wait and observe → walk when target passes. Speed modes compose into tactics. +- **v0.1 minimum:** Walk + Sprint only. Careful deferred. + +**Tyre — Tile-based movement, non-negotiable:** +- Entire server stack is tile-based: shadowcasting, chunks, pathfinding. Free movement introduces floating-point non-determinism (D-010 violation). +- Client-side Tween interpolation (100-150ms) provides visual smoothness — character glides between tiles, player never sees grid snapping. +- Mouse facing is client-side float for rendering; server only needs the facing octant. +- Tile occupancy gives trivial collision detection. + +**Dudley — Tiles-per-tick with specific values:** +- Walk = 1 tile per 2 ticks. Sprint = 1 tile per 1 tick. Sneak = 1 tile per 3 ticks. +- Movement cooldown via tick counting. Mode changes are input events, take effect next tick. +- Sprint suppresses interaction computation (aligns with Gestalt's reduced awareness). +- Total interaction system tick cost: <0.5ms. +- Existing code: `compute_nearby_interactions` in `interaction.rs` (ticket #404). Manhattan distance filtering, VerbOption lists, NearbyInteractionBuffer per observer. + +**Nigel — Movement speed as character identity + free movement:** +- Speed MUST affect perception and should be character-defining, not punishing. "Smuggler sprints (knows the station), detective walks (observing)." +- Second playthrough reveals different monologue triggers on same routes at different speeds. +- **Proposes free movement with tile-based collision** for emergent positioning (eavesdropping angles, peeking corners). +- Active eavesdropping via physical positioning, not passive-only. + +#### Consensus + +- **Multiple movement speeds with perception consequences.** Gestalt's triad is broadly supported. Dudley provides server-side tick values. Nigel endorses speed affecting perception. No one argues for a single movement speed. +- **Client-side interpolation hides the grid.** Tyre's Tween approach (100-150ms) means the player doesn't experience tile-snapping. Uncontested. + +#### Disagreements + +- **TILE-BASED VS. FREE MOVEMENT.** Tyre says tile-based is non-negotiable (D-010 determinism). Dudley's server model assumes tile-based (tiles-per-tick). **Nigel explicitly proposes free movement with tile-based collision** — a direct contradiction. Nigel's argument (emergent positioning for eavesdropping, peeking corners) is a gameplay case; Tyre and Dudley's argument is a technical requirement. This must be resolved in Round 2. +- **Naming: "Careful" vs. "Sneak".** Gestalt calls the slow mode "Careful" (observation focus). Dudley calls it "Sneak" (stealth focus). These imply different design intent — is the slow mode about seeing more or being heard less? Potentially both, but the framing matters. +- **v0.1 scope: two speeds or three?** Gestalt says Walk + Sprint for v0.1 (defer Careful). Dudley specifies all three with tick values. Needs alignment. + +#### Open Questions + +- OQ-01: What is the tile size in world units? +- OQ-02: Does Gestalt's "Careful" = Dudley's "Sneak"? Or are they different modes that could coexist? +- OQ-03: How does Nigel's emergent positioning (eavesdrop angles, peek corners) work in a tile-based system? Can Tyre's interpolation provide the *feel* of free positioning while maintaining tile-based simulation? + +--- + +### 1B. Interaction (Cursor, Verbs, Menus) + +#### Cursor Design + +**Ozzie — Cursor duality validated:** +- Switching between interaction prompt and weapon reticle feels natural (every immersive sim does this). Facing direction bridges the modes. +- 100-150ms morph animation between cursor states. +- **KEY PROPOSAL:** Weapon-selected mode suppresses interaction prompts unless player de-selects weapon or holds modifier key (Shift). "Combat intent trumps social intent." + +**Araminta — Four cursor states with insert styling (full visual spec):** + +| State | Visual | Color | Behavior | +|-------|--------|-------|----------| +| Default | Four thin inward-pointing ticks, bloom | White-blue #c8d0e0 | Barely visible, insert's own cursor | +| Entity hover | Ticks expand outward (150ms), corner brackets frame entity | 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 — "green tint aiming at friend should feel wrong" | + +- All transitions 150ms linear. z-layer 7. Never changes by zone or narrative state (D-045 compliance). + +**Stig — Four cursor states (UX spec):** + +| State | Visual | Behavior | +|-------|--------|----------| +| Default | Small dot/minimal crosshair | Barely there | +| Entity hover (single action) | Floating label in insert styling | Fades in ~150ms. Bloom pulse on entity outline. Verb tooltip visible | +| Entity hover (multi-option) | Primary verb + chevron "Talk ▸" | Left click opens compact vertical list anchored to entity position. 2-4 options max | +| Weapon/aim | Sharper reticle | Replaces cursor, no bloom | + +- **Critical UX rule:** cursor changes on LOS, not just proximity. Interaction triggers on click within ~2 tiles. +- Labels render on z-layer 6 (insert overlay). **Diegetic test:** if insert is off, labels disappear. Passes the "is this information from the character's implant?" test. +- References: Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list). + +#### Entity Interaction Menu + +**Stig — Vertical list for entities:** +- Entity interactions use a compact vertical list (not radial). 2-4 options, anchored to entity position. +- Rationale: few options, ordered by priority, quick to scan. Radial reserved for world menu (different action type, different pattern). + +**Araminta — Pushes for radial on entity interactions too:** +- Disagrees with Stig. Prefers consistent radial pattern across both entity and world interactions. + +#### World Menu (Right-Click) + +**Stig — Radial, 4 spokes:** + +| Spoke | Icon | Function | +|-------|------|----------| +| Observe | Eye | Examine room/environment | +| Insert | Phone | Open neural implant UI | +| Comms | Signal | Communications | +| Wait | Clock | Pass time | + +- Drag-release for power users, click-click for newcomers. +- Geometric lines, thin spokes with icons, nearly transparent. Renders on z-layer 6. +- **v0.1:** Start with 2 spokes (Observe + Insert), scale to 5-6 later. + +#### Verb Computation (Server) + +**Dudley — Two-phase pipeline:** +- **Phase 1 (simulation, no KG):** Compute maximum possible verb set for entity based on ObjectType component. + - ObjectType enum: Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets. +- **Phase 2 (observer, reads KG):** Filter by character's knowledge state. + - Confront requires KnowsDetails+ confidence tier. + - POI priority flips applied. + - Contradiction markers added where knowledge conflicts detected. +- Two interaction tiers: **visual** (requires LOS) and **audio** (medium range, no LOS, passive only). +- Extends existing `compute_nearby_interactions` pipeline (NearbyInteractionBuffer per observer, ObserverSnapshot for knowledge-based adjustments). +- 9-step per-tick pipeline. Interaction at steps 4-5. One-tick knowledge delay (intentional, imperceptible at 100ms tick rate). + +**Nigel — Character-archetype verb sets:** +- Same entity, different verbs depending on character. Same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag". +- Access systems differ: smuggler = social engineering + physical bypasses; detective = institutional authority + lattice authorization. +- **v0.1 minimum verbs:** Examine (universal), Talk (universal), Use (contextual), + 2-3 character-specific verbs. + +**Dudley — Eavesdropping is passive:** +- NO explicit "eavesdrop" or "listen" verb. Sound propagation is passive per D-018. "Your ears are always on." +- Position yourself in medium range → monologue triggers automatically. + +**Nigel — Active eavesdropping via positioning:** +- Eavesdropping should be active — player physically positions themselves to overhear. Not purely passive. +- (Note: this may be compatible with Dudley's "no verb" approach — the *action* is physical movement, not a button click — but the *framing* differs. Dudley emphasizes passivity; Nigel emphasizes player agency in positioning.) + +#### Consensus + +- **Cursor duality is viable.** Ozzie, Araminta, Stig all agree interaction/weapon cursor coexistence works. Visual spec provided. +- **LOS gates interaction.** Dudley (visual tier requires LOS), Stig (cursor changes on LOS). Uncontested. +- **Two-phase verb computation.** Dudley provides the architecture. Nigel's character-archetype verbs fit as Phase 2 observer filter rules. Compatible. +- **No explicit eavesdrop verb.** Dudley and Nigel agree there's no "listen" button — but differ on emphasis (see below). + +#### Disagreements + +- **ENTITY MENU: VERTICAL LIST VS. RADIAL.** Stig proposes vertical list for entities, radial for world. Araminta pushes for radial for entities too. Direct disagreement. Needs Round 2 resolution. +- **Eavesdropping framing: passive vs. active positioning.** Dudley: "your ears are always on" (passive). Nigel: player actively positions to overhear (agency emphasis). Mechanically similar but the design framing affects how the tutorial teaches it and whether positioning aids are provided. + +#### Open Questions + +- OQ-04: Proximity threshold distance (tiles) for interaction availability? Stig says ~2 tiles for click. Ozzie says ~3 tiles for ambient acknowledgment. These may be different thresholds (acknowledge at 3, interact at 2). +- OQ-05: Can you interact through a doorway (LOS exists but distance is >1 tile)? +- OQ-06: Does weapon-mode suppressing interaction (Ozzie's Shift-modifier proposal) interact with Dudley's sprint-suppresses-interaction? Layered suppression rules? +- OQ-07: Stig's diegetic test (labels disappear if insert off) — does this mean characters without an insert get NO interaction prompts? Or does the cursor still change shape? + +--- + +## Layer 2: Fog & Perception UX + +### Fog Rendering + +#### Proposals + +**Araminta — Full fog visual spec:** + +| Fog Layer | Treatment | +|-----------|-----------| +| **Clear (vision cone)** | Soft gradient edge over 3-4 tiles, no hard line. Darkwood approach. | +| **Light fog (peripheral)** | Desaturated 40-50%, brightness -30%. Slow animated Perlin noise overlay (8-10s cycle). Entity D-033 colors visible but reduced. | +| **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." | +| **Unexplored + maps app** | Geometric wireframe outlines in #333340. Insert data aesthetic. | +| **Unexplored, no maps** | Solid near-black #12141a. Information zero. | + +- Three techniques for alive fog: (1) animated noise shader, (2) fog as information surface (pings, ghosts, sensor data), (3) zone temperature memory. +- "Fog is not darkness — it's the absence of your attention." + +**Tyre — Performance confirmed:** +- Total fog budget: <1ms/frame. +- Vision cone IS the PointLight2D lighting system. +- Fog = natural darkness + noise shader on CanvasGroup (Layer 5). +- Sound pings = 0-5 sprites. Entity ghosts = 0-10 sprites typical. +- "Godot 4 eats this for breakfast." Real concern is visual readability, not GPU budget. + +### Fog Entities (Recognition, Pings, Sensor Overlays) + +**Araminta — Entity visualization in fog:** + +| Entity State | Visual Treatment | +|-------------|-----------------| +| **Sound ping** | 2-3 thin concentric expanding rings from source direction. Insert white-blue color. Fade over 1.5s. Loud = 3 rings, bright, fast. Quiet = 1 ring, faint, slow. | +| **Recognized entity** | D-033 relationship color glow. Faint identifying silhouette feature (e.g., "Kael's vest", "Lera's apron"). 0.8s breathing pulse. Position drift ±0.5 tiles (approximate, not exact). | +| **Unrecognized entity** | Neutral grey #555566 blob. No silhouette detail. No identifying features. | +| **Recognition transition** | Grey → D-033 color + silhouette feature over ~0.3s. | + +**Ozzie — Recognition as cognitive event (four sub-proposals):** +1. **Recognition delay 0.5-1s.** Blob resolves into recognized figure with D-033 color bleeding in. Monologue fires DURING the resolve, not after. ("Those footsteps... that's Kael's walk.") +2. **Context creates emotional register.** Same mechanic, different feeling based on player knowledge. Hearing Kael at 0300 in corridor B-7 when he said he'd be home = "blood runs cold." +3. **Sensor recognition feels DIFFERENT from natural recognition.** Natural = organic resolve over time. Thermal/sensor = digital snap with biometric ID. Different monologue voice too. +4. **False-positive shapes in fog.** Fog should occasionally produce shapes that look like people but aren't. "So the brain never stops watching." + +**Nigel — Fog as replayability mechanism:** +- Same foggy corridor shows different things to different characters based on knowledge graph. Smuggler recognizes Kael = green icon. Detective sees same entity = gray blob. +- Different perception loadouts = different play patterns (thermal = numbers game, natural = recognition game). +- Sound ping detail varies by audio analysis capability. +- "Scales without engineering" — the knowledge graph already does the work. + +**Gestalt — Perception mode cap:** +- **Hard cap: 2 active perception modes + passive insert = 3 total layers.** +- At 3 active modes = "visual salad." +- Mode switching takes 1-2 seconds (insert reconfiguring). Not instant — a deliberate choice. +- Build decisions: smuggler thermal+audio vs. detective tracking+cameras. +- **v0.1:** Natural vision + audio pings only. Zero active perception modes. Modes are post-v0.1. + +#### Consensus + +- **Shader-based fog, not particles.** Araminta proposes, Tyre confirms. No dissent. +- **Fog recognition has a delay.** Ozzie (0.5-1s organic resolve) and Araminta (0.3s grey→color transition) both propose delays. Ozzie's is longer and more psychological; Araminta's is visual transition timing. These may layer (0.3s visual transition within a 0.5-1s cognitive beat). Direction is unanimous. +- **Monologue fires during recognition, not after.** Ozzie is explicit. Aligns with D-016 (monologue as perception). The monologue IS the recognition. +- **Fog is knowledge-graph-driven.** Nigel provides the replayability case. Gestalt's signal-vs-answer framework supports it. Dudley's Phase 2 observer filter already computes per-character fog entity visibility. Architectural alignment. +- **Natural vs. sensor recognition should feel different.** Ozzie proposes organic resolve vs. digital snap. No dissent. Extends the "perception mode = character build" concept. + +#### Tension + +- **Recognition delay timing: 0.3s vs. 0.5-1s.** Araminta's visual spec says 0.3s for the grey→color+silhouette transition. Ozzie says 0.5-1s for the full cognitive beat. These may compose (0.3s visual within a longer monologue beat), but the intended player experience differs: Araminta's is a smooth visual transition; Ozzie's is a dramatic moment of realization. Needs alignment. +- **Perception mode cap: accepted?** Gestalt proposes hard cap at 2 active modes. v0.1 has zero active modes anyway, so this doesn't affect immediate scope. But it constrains later design. No explicit endorsement or rejection from others. + +#### Open Questions + +- OQ-08: Does Ozzie's false-positive fog shapes have gameplay implications (player investigates nothing), or is it purely atmospheric? +- OQ-09: How does Araminta's zone temperature memory persist technically? Does the server track "time since player last visited this tile," or is it client-side only? +- OQ-10: Ozzie says sensor recognition should use a "different monologue voice." Does this mean different text styling, different narrator tone, or a separate monologue channel? +- OQ-11: Gestalt's mode-switch time (1-2s) — is this a UI animation, a server-side cooldown, or both? + +--- + +## Layer 3: Information Access & the Implant UI + +### Proposals + +**Gestalt — Signal vs. answer framework:** +- Game pushes SIGNALS. Player pulls ANSWERS. Three-step loop: PUSH → DECIDE → PULL. +- **Push types:** sensory monologue, sound pings, D-033 color shifts, observation monologue, urgent chime, unprompted NPC disclosure, news ticker. +- **Pull actions:** walk to location, talk to NPC, examine object, check insert, follow someone, switch perception mode, cross-reference data. +- **Sweet spot rule:** "If the player can solve mysteries standing still reading monologue = too much push." +- Insert scans public lattice data only. Secrets require engagement (talk, observe, investigate). + +**Ozzie — Implant as tool, not encyclopedia:** +- Implant organized by **DATA TYPE, not quest**. No "current investigation" tab. Categories: Comms, Cargo Logs, Personnel Records, Station Maps, News. +- **No highlights or markers** on relevant data. Player reads raw information and notices discrepancies themselves. +- Right-click world menu should feel like "reaching for your phone." Game keeps running or soft-pauses. +- Monologue IS the nudge — provides motivation without hand-holding. Player connects the dots. + +**Tyre — Implant pause/overlay architecture:** +- Auto-pause in SP: client sends `PauseSimulation`, server freezes. +- Overlay on Layer 6, game world visible beneath. Events/monologue still render on Layer 7 with implant open. +- MP future-proofing: remove the pause check. Zero additional complexity. +- "Better single-player UX too — overlay is superior to full-screen takeover." + +**Nigel — Implant should NOT pause (disagrees with Tyre):** +- Game should not pause during implant access. Creates meaningful time tradeoff — checking your insert while the world moves around you. +- Same right-click menu, different universe behind it per character. Detective sees case files + lattice analysis. Smuggler sees contacts + drop schedules. +- Maps app vs. no maps app is a "HUGE replayability lever." +- Scan effectiveness should vary per character seed. + +**Stig — Radial world menu (4 spokes → 2 for v0.1):** +- Observe (eye), Insert (phone), Comms (signal), Wait (clock). +- Drag-release for power users, click-click for newcomers. +- v0.1: 2 spokes only (Observe + Insert). Scale to 5-6 later. + +#### Consensus + +- **Signal/answer distinction is the design framework.** Gestalt proposes it, Ozzie reinforces it with the "tool not encyclopedia" framing. No dissent. The implant serves player-initiated pulls, not system-initiated dumps. +- **Implant organized by data type, not quest.** Ozzie is explicit. Aligns with Gestalt's framework (player constructs meaning from raw data). No dissent. +- **No highlights on implant data.** Ozzie proposes. Consistent with invisible locked options (Layer 4) and the information asymmetry principle. No dissent. +- **Implant is overlay, not full-screen.** Tyre and Stig agree: game world stays visible. No dissent. + +#### Disagreements + +- **IMPLANT PAUSE: YES OR NO.** Tyre says auto-pause SP (better UX, cleaner architecture). Nigel says no pause (meaningful time pressure, player must choose when to check insert). Direct disagreement. Tyre's position is the technically simpler path; Nigel's creates more tension but complicates UX. Both acknowledge MP won't pause. The question is whether SP should match MP behavior from the start. + +#### Open Questions + +- OQ-12: Does Gestalt's "insert scans public lattice data only" mean the insert NEVER reveals secrets, or that it reveals secrets only after they've been learned through other means (talk, observe)? +- OQ-13: If implant data has no highlights (Ozzie), how does the player know when new information has been added? Is there a "new data received" notification, or must they check proactively? +- OQ-14: Nigel's "scan effectiveness varies per character seed" — does this mean the same scan action produces different data, or that different characters have different scan actions available? + +--- + +## Layer 4: Dialogue Initiation & Confrontation UX + +### Dialogue Initiation + +**Ozzie — Conversation starts before you click (five proposals):** +1. Proximity triggers ambient acknowledgment at ~3 tiles. NPC turns, player's monologue fires: "There's Kael." +2. NPC posture/animation reacts to approach. Hostile NPC = tense. NPC hiding something = Tier 2 ambiguous animation (D-047). +3. Dialogue box should **EMERGE, not APPEAR.** No hard cut. Text rises from NPC position. +4. Walking away IS an option with consequences. NPC reacts, next conversation references it. +5. **Confrontation needs PHYSICAL STAGING.** Player gets close, camera tightens slightly, ambient sound dips. THEN confrontation option appears. The space changes before the words do. + +**Stig — Dialogue UI spec:** +- Bottom 25% of screen. Game world stays live above. +- Layout: NPC speech top, player response options below, left-aligned. +- **NO PORTRAITS** — the NPC is on screen; a portrait is redundant. +- Monologue floats in normal position ABOVE dialogue box (z-layer 7). Spatial separation means monologue and dialogue can contradict each other visually. +- Walk-away via WASD. Dialogue fades over ~300ms. No close button. + +### Confrontation + +**Paula — Same box, different weight (four mechanisms):** +1. **Confrontation options written in character's internal voice** — italicized, first-person. Regular option: "Shift schedule." Confrontation option: *"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 to confrontation:** NPC shifts to Tier 2 animation (D-047). Entity D-033 color may fade. Monologue frequency spikes during exchange. Available topics narrow post-confrontation. +4. **Walk-away contaminates social space.** Walking away mid-confrontation affects NPC routine. Monologue notes it later. + +**Paula — Explicitly NOT these:** +- NOT a separate confrontation mode or UI +- NOT timed responses +- NOT a visible relationship meter +- NOT correct/incorrect dialogue approaches + +### Invisible Locked Options + +**Gestalt — Non-negotiable, 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. +- **Exception:** NPC holding back is communicated via monologue, not locked UI. (*"She changed the subject. Fast."*) + +**Nigel — Invisible locks non-negotiable:** +- Grayed-out options = metagaming checklist. Invisible = genuine surprise on replay. +- D-028's four dialogue layers are already a replayability machine. +- Walk-away consequences should vary by NPC tolerance threshold per seed — no metagaming social rules. + +#### Consensus + +- **Invisible locked options.** Gestalt and Nigel are emphatic. Strongest consensus point in the entire round. No dissent from any participant. +- **Same dialogue box for confrontation, different weight.** Paula defines the mechanism (italic voice, monologue beat, world response). Stig provides the UI container (bottom-25%, monologue above). These compose naturally. No dissent. +- **Walk-away via WASD with consequences.** Stig (mechanic: WASD, 300ms fade), Ozzie (consequences: NPC reacts, next conversation references), Paula (contaminates social space). All aligned. +- **No portraits in dialogue.** Stig is explicit. NPC is on screen — portrait is redundant. No dissent. + +#### Tension + +- **Pre-dialogue proximity acknowledgment: scope?** Ozzie proposes ambient NPC reactions at ~3 tiles. Compelling for feel. But requires NPC AI behavior (turning, brief line) and potentially server-side proximity events. No one rejected it, but no one confirmed feasibility for v0.1. +- **Confrontation physical staging.** Ozzie proposes camera tightens + ambient sound dips before confrontation option appears. Araminta hasn't commented on camera behavior. Tyre hasn't confirmed feasibility. Sound design not yet covered (Ozzie flags missing audio throughout). + +#### Open Questions + +- OQ-15: How does Ozzie's pre-dialogue acknowledgment work technically? Server sends proximity event, or client-side animation triggered by distance? +- OQ-16: What is the timing of Paula's pre-delivery monologue beat in Stig's UI? Monologue appears above → 1-2s pause → confrontation line appears below? Does the player see the option, select it, then the beat plays before delivery? +- OQ-17: Can you resume a conversation after WASD walk-away? Or is it a one-shot interaction per approach? +- OQ-18: How do Nigel's "dialogue access tiers per character" relate to D-041 confidence hierarchy? Same system or layered? +- OQ-19: Ozzie's physical staging (camera tighten, sound dip) — does this apply to ALL confrontation options or only the first time a confrontation is available with a given NPC? + +--- + +## Layer 5: Inventory & Physical Interaction + +### Proposals + +**Gestalt — Knowledge-primary, physical-secondary:** +- Knowledge is the default. "You SAW the manifest" (KG records it), not "you HAVE the manifest." +- Physical items exist ONLY where carrying creates genuine risk/reward. +- **Evidence decision matrix:** + - Observe: safe, deniable, KG records observation. + - Take: proof obtained, but scene is disrupted. + - Photograph: proof without disruption (requires implant capability). + - Leave + Tell: delegate to authority (NPC acts on your tip). +- 0-3 physical items at any time. No inventory grid, no weight, no bags. +- **v0.1:** Contraband packages, 1-2 evidence objects, access cards. + +**Tyre — No inventory in v0.1 (zero physical items):** +- Zero success criteria require carrying items. +- Detective = knowledge-only is a natural model (institutional authority). +- Smuggler = interaction points (terminals, containers), not carried objects. +- Access states = binary character properties + knowledge, not items. +- Deferring inventory saves 3-4 sprints of development. +- "Everything built extends later" — knowledge-first architecture doesn't prevent adding physical items post-v0.1. + +**Paula — Same KG, different presentation per archetype:** +- **Detective:** Case-file-style entries. Structured data: what/where/when/source/confidence. Insert suggests links between connected facts. Institutional authority means observation IS evidence. +- **Smuggler:** Personal notebook organized by PERSON. Informal voice. Notes, not structured fields. No contradiction flags — monologue does the connecting. Evidence is transactional leverage. +- Same underlying UI component, different presentation layer. Same knowledge graph underneath. +- **Physical evidence split:** Detective mostly knowledge-graph (institutional authority — their word carries weight). Smuggler needs tangible proof (copied manifest, physical item, recording) because their word doesn't. +- **v0.1:** KG-primary. Smuggler gets 2-3 physical evidence items specifically for leverage proof. + +**Nigel — Different verb sets reinforce character identity:** +- Same crate → smuggler sees "Move/Stash", detective sees "Scan/Flag." +- Access systems differ: smuggler = social engineering + physical bypasses; detective = institutional authority + lattice authorization. +- Physical inventory would flatten character differences — "everyone picks up the same crate the same way." + +#### Consensus + +- **Knowledge-primary model.** Universal agreement. Knowledge graph is the core "inventory." No one argues for a traditional inventory system. +- **Same KG, different presentation per archetype.** Paula proposes, Nigel reinforces. Uncontested. This is both a UI feature and a character identity mechanism. + +#### Disagreements + +- **v0.1: ZERO PHYSICAL ITEMS VS. A FEW.** Tyre says zero (saves 3-4 sprints, everything extends later). Gestalt says 0-3 items with risk/reward (contraband, evidence, access cards). Paula says smuggler specifically needs 2-3 physical items for leverage proof. Tyre's position is scope-minimal; Gestalt and Paula argue that at least a few physical items are needed to demonstrate the risk/reward concept and differentiate the smuggler's gameplay loop. +- **Smuggler gameplay without physical items.** If Tyre's "zero inventory" holds, how does smuggling work? Purely via terminal interactions and abstract manifests? Nigel's "different verb sets on same crate" implies physical interaction with objects, which may not require *carrying* them but does require *manipulating* them in the world. + +#### Open Questions + +- OQ-20: Does "no inventory" mean no *carrying*, or no inventory *UI*? Can the player pick up an access card that goes to a binary character property (has_card = true) rather than an inventory slot? +- OQ-21: How does Paula's archetype-specific presentation work technically? Skin on same implant app, or different apps entirely? +- OQ-22: Gestalt's evidence decision matrix (Observe/Take/Photograph/Leave+Tell) — is this a set of verbs on evidence objects, or a design framework? Should these be the actual verb options in Dudley's Phase 1 computation? +- OQ-23: If smuggler needs 2-3 physical items (Paula) but Tyre defers all inventory — can interaction-point abstraction support "smuggler uses a physical manifest as leverage" without an inventory system? + +--- + +## Cross-Cutting Themes + +### Theme 1: Player Attention as Finite Resource + +**Gestalt's cross-layer insight:** All five layers are expressions of one mechanic — player attention is finite. Movement speed determines what you notice. Fog determines what you can see. The implant gives you data but costs attention. Dialogue requires focus. Physical items create risk that demands monitoring. The game is fundamentally about where you choose to point your attention. + +### Theme 2: Character Differentiation Across Every Layer + +Every layer produces different experiences per character archetype: + +| Layer | Detective | Smuggler | +|-------|-----------|----------| +| **Movement** (Nigel) | Walks, observes, more monologue | Sprints, knows the station, less monologue | +| **Fog** (Nigel) | Recognizes different entities | Recognizes different entities | +| **Implant** (Nigel, Paula) | Case files, lattice analysis | Contacts, drop schedules, personal notebook | +| **Dialogue** (Nigel) | Institutional access tiers | Social engineering access tiers | +| **Verbs** (Nigel, Dudley) | Scan/Flag/Observe | Move/Stash/Bypass | +| **Evidence** (Paula) | KG-primary (word carries weight) | Needs physical proof (word doesn't carry weight) | + +### Theme 3: Information Asymmetry Principle + +Reinforced from every direction this round: +- **Invisible locks** (Gestalt, Nigel): you don't know what you don't know. +- **No data highlights** (Ozzie): player reads raw information, notices discrepancies. +- **Signal vs answer** (Gestalt): game pushes signals, player pulls meaning. +- **Fog as knowledge window** (Nigel): you can't even see what you're missing. +- **Recognition delay** (Ozzie): information arrives as cognitive process, not data dump. +- **False positives in fog** (Ozzie): you can't even trust what you think you see. + +### Theme 4: Missing Sound Design + +**Ozzie flags:** Sound design is absent from this round. Every interaction needs audio feedback — cursor state changes, fog recognition, implant access, dialogue initiation, confrontation staging. This is a gap for Round 2 or a dedicated follow-up. + +### Theme 5: v0.1 Scope Convergence + +| Agent | v0.1 Floor | +|-------|-----------| +| **Gestalt** | Walk + Sprint (not Careful). Natural vision + audio pings only (zero active perception modes). Invisible locked options. | +| **Tyre** | Tile-based movement. Knowledge-first (zero physical items). Implant auto-pause SP with overlay architecture. | +| **Dudley** | Two-phase verb computation. Passive eavesdropping. <0.5ms tick budget. Walk/Sprint/Sneak with tick values. | +| **Stig** | 2-spoke radial world menu (Observe + Insert). Bottom-25% dialogue box. Vertical entity list. | +| **Nigel** | Fog perception divergence between characters + dialogue access tier differences. "These two prove the replayability concept." | +| **Paula** | KG-primary evidence. Smuggler gets 2-3 physical items for leverage. | + +**Emerging v0.1 floor:** Tile-based movement with Walk + Sprint. Shader fog with KG-driven divergence between characters. Invisible locked dialogue options. Knowledge-only evidence (physical items contested — 0 vs 2-3). Two-phase verb computation. 2-spoke world menu. Bottom-25% dialogue box. + +--- + +## Summary Tables + +### Consensus Points + +| # | Point | Agents | Strength | +|---|-------|--------|----------| +| C-01 | Invisible locked dialogue options | Gestalt, Nigel | Emphatic — both frame as foundational | +| C-02 | Tile-based simulation (determinism) | Tyre, Dudley | Technical requirement, non-negotiable | +| C-03 | Shader-based fog, not particles | Araminta, Tyre | Unanimous, technically confirmed | +| C-04 | Knowledge-primary model (KG is the "inventory") | Gestalt, Tyre, Paula, Nigel | Universal | +| C-05 | Same KG, different presentation per archetype | Paula, Nigel | Uncontested | +| C-06 | Same dialogue box for confrontation, different weight | Paula, Stig | Compositional — mechanism + UI spec | +| C-07 | Signal/answer framework for information access | Gestalt, Ozzie | Uncontested | +| C-08 | Fog recognition has perceptible delay | Ozzie, Araminta | Direction unanimous, timing differs | +| C-09 | Cursor duality viable (interaction ↔ weapon) | Ozzie, Araminta, Stig | Visual spec provided | +| C-10 | Two-phase verb computation (sim max → observer filter) | Dudley, Nigel | Architectural alignment | +| C-11 | Walk-away via WASD with consequences | Stig, Ozzie, Paula | Mechanic + consequences aligned | +| C-12 | No portraits in dialogue | Stig | Uncontested | +| C-13 | Implant organized by data type, not quest | Ozzie | Uncontested | +| C-14 | No highlights on implant data | Ozzie | Uncontested | +| C-15 | Monologue fires during fog recognition, not after | Ozzie | Uncontested | +| C-16 | Natural vs sensor recognition should feel different | Ozzie | Uncontested | +| C-17 | Eavesdropping has no explicit verb | Dudley, Nigel (framing differs) | No "listen" button | +| C-18 | Implant is overlay, not full-screen takeover | Tyre, Stig | Uncontested | + +### Disagreements (Require Round 2 Resolution) + +| # | Issue | Position A | Position B | Stakes | +|---|-------|-----------|-----------|--------| +| D-01 | **Movement model** | Tyre, Dudley: tile-based | Nigel: free movement + tile collision | Server architecture, determinism | +| D-02 | **Entity interaction menu** | Stig: vertical list | Araminta: radial | UI consistency, feel | +| D-03 | **Implant pause in SP** | Tyre: auto-pause | Nigel: no pause (time pressure) | UX feel, MP alignment | +| D-04 | **Physical items in v0.1** | Tyre: zero | Gestalt: 0-3 risk/reward; Paula: 2-3 for smuggler | Scope, smuggler gameplay viability | +| D-05 | **Recognition delay timing** | Araminta: 0.3s visual | Ozzie: 0.5-1s cognitive | Player experience of recognition | +| D-06 | **Eavesdropping framing** | Dudley: passive ("ears always on") | Nigel: active positioning | Tutorial design, player agency emphasis | + +### Open Questions (Aggregate) + +| # | Question | Layer | +|---|----------|-------| +| OQ-01 | Tile size in world units? | Movement | +| OQ-02 | "Careful" (Gestalt) = "Sneak" (Dudley)? Same mode or different? | Movement | +| OQ-03 | How does emergent positioning (eavesdrop, peek) work in tile-based system? | Movement | +| OQ-04 | Proximity thresholds: acknowledge at 3 tiles (Ozzie), interact at 2 (Stig)? | Interaction | +| OQ-05 | Interaction through doorways (LOS but >1 tile)? | Interaction | +| OQ-06 | Weapon-suppression + sprint-suppression interaction? Layered rules? | Interaction | +| OQ-07 | Diegetic cursor test: no insert = no prompts? | Interaction | +| OQ-08 | False-positive fog shapes: gameplay or atmospheric? | Fog | +| OQ-09 | Zone temperature memory: server-tracked or client-only? | Fog | +| OQ-10 | Sensor recognition "different monologue voice": text styling or narrator tone? | Fog | +| OQ-11 | Perception mode-switch time (1-2s): UI animation, server cooldown, or both? | Fog | +| OQ-12 | Insert scans public lattice only — never reveals secrets, or reveals after learned? | Information | +| OQ-13 | No data highlights — how does player know new data was added? | Information | +| OQ-14 | "Scan effectiveness varies per character seed" — different data or different actions? | Information | +| OQ-15 | Pre-dialogue acknowledgment: server event or client animation? | Dialogue | +| OQ-16 | Monologue beat timing in dialogue UI? Selection → beat → delivery flow? | Dialogue | +| OQ-17 | Resume conversation after WASD walk-away? One-shot or persistent? | Dialogue | +| OQ-18 | Nigel's dialogue access tiers vs D-041 confidence hierarchy — same or layered? | Dialogue | +| OQ-19 | Confrontation staging (camera, sound) — every confrontation or first time only? | Dialogue | +| OQ-20 | "No inventory" = no carrying, or no inventory UI? Binary properties (has_card)? | Inventory | +| OQ-21 | Archetype presentation: skin on same app or different apps? | Inventory | +| OQ-22 | Evidence decision matrix (Observe/Take/Photo/Tell): verbs or design framework? | Inventory | +| OQ-23 | Can interaction-point abstraction support smuggler leverage without inventory? | Inventory | + +--- + +*Round 1 compiled by Qatux from full responses relayed via team lead. Individual agent response files not written to disk.* + +--- +--- + +# Round 2: Integration Pass + +**Date:** 2026-02-13 +**Focus:** Cross-layer integration, v0.1 minimum viable interaction model, disagreement resolution +**Lead direction:** 10 specific decisions issued before Round 2 to resolve Round 1 disagreements and set constraints + +--- + +## Lead Decisions (Pre-Round 2) + +The lead reviewed all Round 1 responses and issued the following direction: + +| # | Decision | Resolves | Effect | +|---|----------|----------|--------| +| LD-01 | Dialogue box: max 20% screen height, max-width not percentage-based | Stig's 25% → constrained | Tighter dialogue footprint | +| LD-02 | Default movement speed per character, everyone gets sprint, player agency | T-01 (speed triad vs archetype) | Hybrid: archetype defaults + universal sprint | +| LD-03 | Tile-based confirmed, same-tile occupancy provision | **D-01 RESOLVED** | Tyre/Dudley position wins. Occupancy system added for positioning depth | +| LD-04 | No hard perception mode cap — soft monologue warning | T-02 partially resolved | Gestalt's hard cap rejected. Diegetic soft warning instead | +| LD-05 | Smuggler needs inventory in v0.1 | **D-04 RESOLVED** | Gestalt/Paula position wins over Tyre's zero-inventory. Tyre adapts with minimal impl | +| LD-06 | Fog mechanic in v0.1 — crucial | Confirms Nigel's v0.1 floor | KG-driven fog divergence is must-have | +| LD-07 | Eavesdropping: passive + positioning improves range/quality | **D-06 RESOLVED** | Hybrid of Dudley (passive base) + Nigel (positioning agency) | +| LD-08 | Single cognitive delay for fog recognition | **D-05 PARTIALLY RESOLVED** | Direction set (one delay, not instant). Exact values still contested | +| LD-09 | Message history/memory — design for, not build in v0.1 | New constraint | Architecture accounts for it, implementation deferred | +| LD-10 | Araminta's reticle confirmed | Cursor visual spec | Araminta's geometric insert-styled cursor is the spec | + +--- + +## Round 1 Disagreement Resolution Tracker + +| R1 # | Issue | Resolution | Status | +|-------|-------|------------|--------| +| D-01 | Tile-based vs free movement | **RESOLVED.** Lead confirms tile-based (LD-03). Nigel converts: "tiles are BETTER for replayability" — discrete positions = finite meaningful choices. Doorway decision, corner peek, eavesdrop corridor are all spatial puzzles that tiles make legible. Same-tile occupancy provision addresses Nigel's positioning depth concern. | Closed | +| D-02 | Entity menu: vertical vs radial | **UNRESOLVED.** Lead asked Araminta to make the case (task #16). Araminta argues spoke radial (not pie). Stig defends vertical list. Both present strong arguments. Needs lead call. | Open | +| D-03 | Implant pause in SP | **NOT EXPLICITLY ADDRESSED.** Lead decisions don't directly resolve Tyre vs Nigel on SP pause. Tyre's overlay architecture was described. No agent raised it in Round 2. May be implicitly resolved by Tyre's architecture (overlay works both ways). | Needs confirmation | +| D-04 | Physical items in v0.1 | **RESOLVED.** Lead says smuggler needs inventory (LD-05). Tyre adapts with minimal implementation: SmallVec<3>. Dudley provides server model: BTreeMap + per-archetype capacity. Paula specifies 3 items. | Closed | +| D-05 | Recognition delay timing | **PARTIALLY RESOLVED.** Lead says single cognitive delay (LD-08). But values still contested: Gestalt 0.8-1.2s vs Ozzie 0.6s base/0.3s urgent. | Values need lead call | +| D-06 | Eavesdropping framing | **RESOLVED.** Lead says passive + positioning improves quality (LD-07). Dudley provides the mechanism: ListeningFocus accumulates stationary_ticks. Stillness + facing = better eavesdropping. Passive base, positioning enhances. | Closed | + +--- + +## Integration Pass by Layer + +### Movement (Updated) + +**Gestalt — Sprint perception refined:** +- Sprint reduces INTERPRETATION, not DATA. Visual overlays still render; monologue is suppressed. You see everything, but your character doesn't think about it. +- **Multiplicative perception formula:** `movement_modifier × perception_load_modifier`. Allows sprint + perception modes to interact mathematically rather than through ad-hoc rules. +- **Rethinks sprint-suppresses-interaction:** Physics handles it naturally — at sprint speed you pass through interaction radius too fast to click. No need for explicit suppression. "The world doesn't change for you; you're just moving too fast for it." +- **Careful mode MUST be in v0.1** — it's required for the eavesdropping positioning mechanic (LD-07). You can't do "careful approach to overhear" without Careful mode. +- Full 5-minute gameplay trace demonstrating all movement modes in sequence. +- **Cognitive delay proposal: 0.8-1.2s.** + +**Dudley — Movement profiles and sprint suppression:** +- MovementProfile component per archetype: different base speeds, different default modes. +- Same-tile occupancy via TilePresence layers: Standing, Ground (prone), Fixture (furniture). Multiple entities can share a tile in different layers. +- **Sprint: explicit suppression.** Removes peripheral vision flag, filters monologue to urgent-only, clears interaction buffer. Disagrees with Gestalt's "physics handles it" — wants explicit server-side suppression for determinism. +- Eavesdrop focus: ListeningFocus component accumulates `stationary_ticks`. Stillness + facing direction toward sound source = improved eavesdropping quality. Emergent from existing tick pipeline. +- **v0.1 priority:** Occupancy system + minimal inventory + movement profiles + sprint perception changes. ListeningFocus deferrable if needed. + +**Tyre — Same-tile occupancy implementation:** +- TilePresence component with Standing/Prone/Seated states. ~150 lines of code. +- Enables: two people in a doorway, sitting at furniture, crouching behind cover. +- Addresses Nigel's emergent positioning concern within tile-based system. + +**Nigel — Tiles are better (conversion):** +- Full conversion from Round 1 free-movement position. "Tiles are BETTER for replayability." +- Discrete positions = finite meaningful choices. Every tile matters. +- Specific spatial puzzle examples: doorway decision (block or let pass), corner peek (which tile gives LOS), eavesdrop corridor (which tile is in audio range). +- Full 10-minute smuggler vs detective scenario trace: **12 points of divergence, zero scripted branching.** All divergence emerges from different knowledge graphs interacting with the same tile-based world. + +#### Movement Consensus (Round 2) + +- **Tile-based: unanimous.** Nigel converted. D-01 fully closed. +- **Three speeds in v0.1.** Gestalt argues Careful is required for eavesdropping. Dudley provides tick values. No dissent on including all three. +- **MovementProfile per archetype.** Dudley proposes, aligns with LD-02 (default speed per character). Uncontested. +- **Same-tile occupancy.** Tyre provides implementation. Addresses positioning depth. Uncontested. + +#### Movement Disagreement (Round 2) + +- **NEW D-07: Sprint interaction suppression mechanism.** Gestalt says physics handles it (player passes through radius too fast — no explicit rule needed). Dudley says explicit server-side suppression (clears buffer, filters monologue to urgent-only). Gestalt's approach is elegant but may have edge cases (what if player sprints INTO an entity?). Dudley's is deterministic but adds suppression rules. **Needs lead call.** + +--- + +### Fog & Perception (Updated) + +**Gestalt — Cognitive delay and perception load:** +- Proposes cognitive delay of **0.8-1.2s** for fog recognition. +- Multiplicative perception formula applies: more active perception modes = slight delay increase (perception load). +- Sprint reduces interpretation: overlays render but monologue is suppressed. You see the fog entity but your character doesn't comment. + +**Ozzie — Cognitive delay with urgency split:** +- Base cognitive delay: **0.6s.** +- Urgent override: **0.3s** — when context makes recognition urgent (known hostile, anomalous presence, contradicts known schedule). Monologue fires faster: "That's — wait, that's Kael" (0.3s) vs casual "...sounds like Kael's walk" (0.6s). +- **Sprint "double-take":** Sprint suppresses routine social awareness BUT NOT anomaly detection. If you sprint past something wrong, monologue fires after a brief delay — the character noticed even though the player was moving fast. "Sprint double-take." +- 14 audio assets identified for v0.1 interaction sounds: 6 new (cursor hover, cursor weapon-mode, fog recognition sting, implant open/close, dialogue emerge, confrontation dip) + D-038's existing 8. + +**Araminta — Multi-overlay visual composition:** +- 3-4 simultaneous perception overlays ARE readable if each mode uses a different visual channel: + - Natural vision = entity shapes and silhouettes + - Thermal = color temperature blobs + - Audio = expanding rings + - Sensor = digital scan lines +- **Soft degradation at high load:** scan-line interference effect when running many overlays. Diegetic — the insert is under strain. This replaces Gestalt's hard cap with a visual/narrative warning. +- Full screen composition map showing all UI elements positioned. + +**Nigel — Fog replayability validation:** +- The 10-minute scenario trace includes fog divergence at 4 of the 12 divergence points. +- Smuggler recognizes dock workers in fog = navigates confidently. Detective sees grey blobs = proceeds cautiously. +- v0.1 fog divergence passes D-027 (replayability criteria) second-playthrough test. + +#### Fog Consensus (Round 2) + +- **Single cognitive delay (LD-08).** Direction is unanimous. One delay system, not instant recognition. +- **Soft perception warning, not hard cap (LD-04).** Araminta's diegetic soft degradation (scan-line interference) replaces Gestalt's hard 2-mode cap. Gestalt's hard cap is rejected by lead. +- **Sprint double-take.** Ozzie's proposal that anomaly monologue survives sprint. No dissent. Elegant solution — sprint suppresses routine, not urgent. + +#### Fog Disagreement (Round 2) + +- **NEW D-08: Cognitive delay values.** Gestalt proposes 0.8-1.2s (longer, contemplative). Ozzie proposes 0.6s base / 0.3s urgent (shorter, with urgency split). These are different philosophies: Gestalt wants every recognition to feel like a moment; Ozzie wants urgency to compress the moment. Both agree the delay exists. **Needs lead call on values and whether urgency split is adopted.** + +--- + +### Interaction & Menus (Updated) + +**Stig — Dialogue box revised + vertical list defense:** +- Dialogue box: revised to 50% width centered (lead then directed max-width instead of percentage). +- Max 3 response options visible at once. +- **Defends vertical list for entity interactions:** Variable-length text options (e.g., *"I saw you in corridor B-7"*) break radial spatial memory. Radial works for fixed categories (world menu spokes) but not variable-text verbs. List handles 1-4 options cleanly; radial wastes space at 1-2 options. +- Smuggler inventory UI: pocket icons bottom-right corner, 40x40px each. No empty slots displayed — icons appear only when items are carried. +- **Message history:** Store all dialogue exchanges in the knowledge graph. Display later as a timeline when message history feature is built (LD-09). Design the KG schema now. + +**Araminta — Spoke radial case for entity menus:** +- NOT pie radial (angular sectors) — SPOKE radial (nodes on spokes extending from entity). +- **Key argument:** When knowledge changes unlock a new verb, a new spoke GROWS from the entity. This is a **qualitative** change (geometry transforms — the shape itself is different) vs a quantitative change (list gets longer — same shape, more items). The player experiences "something new is here" rather than "the list is longer." +- Spoke growing = D-041 confidence tier advancement made visible. New spoke appears when you cross a knowledge threshold for that entity. +- Consistent with world menu radial (same interaction grammar). + +**Dudley — Inventory server model:** +- Smuggler inventory: `BTreeMap` per entity. +- Capacity per archetype: smuggler 4 slots, detective 2 slots. +- Carried items are **PRIVATE** — they exist behind the information boundary. Other entities' inventories are not visible to the player unless revealed through interaction (search, scan, confrontation). +- Same-tile occupancy layers: Standing, Ground, Fixture. Enables sitting at terminals, crouching behind cover, two people in a doorway. + +#### Interaction Consensus (Round 2) + +- **Dialogue max-width, not percentage.** Lead direction (LD-01). Stig adapts. +- **Max 3 response options.** Stig proposes. Consistent with invisible locks — if you see 3, there might be 6 you can't see. +- **Inventory is private (info boundary).** Dudley proposes. Aligns with information asymmetry principle. Uncontested. +- **Pocket-icon inventory UI (smuggler).** Stig proposes. Minimal, no empty slots. Uncontested. +- **Message history: schema now, display later.** Stig + Paula aligned with LD-09. Store in KG, defer UI. + +#### Interaction Disagreement (Round 2) + +- **D-02 CONTINUED: Entity menu — spoke radial vs vertical list.** Araminta's spoke radial: new knowledge = new spoke grows (qualitative geometry change, consistent with world menu). Stig's vertical list: variable-length confrontation text breaks radial spatial memory, radial wastes space at 1-2 options. Both arguments are strong. **Needs lead call.** +- **NEW D-09: Dialogue width.** Stig proposed 50% width centered. Lead directed max-width instead of percentage. Exact max-width value not specified. Minor — needs a pixel value. + +--- + +### Dialogue & Confrontation (Updated) + +**Ozzie — Confrontation staging for v0.1:** +- Simplified from Round 1's full staging proposal. +- v0.1 scope: proximity check + audio dip + text styling (italic/monologue beat). Camera tighten deferred to post-v0.1. +- Sprint "double-take" applies to confrontation awareness too — if you sprint past someone with a pending confrontation, monologue might fire retroactively. + +**Paula — Walk-away and evidence chains:** +- Walk-away from confrontation has three distinct phases: + 1. **Immediate break:** Dialogue fades (Stig's 300ms). Silence. + 2. **NPC reacts:** NPC animation shifts. May call after you. Routine may change. + 3. **KG records incompleteness:** The knowledge graph records that the confrontation was initiated but not completed. This is queryable — affects future dialogue, monologue, and NPC behavior. +- **Three specific smuggler inventory items for v0.1:** + 1. **Manifest copy:** Proves cargo discrepancy. Leverage proof — KG alone isn't enough because 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. +- Full evidence chain trace: detective structured path (observe → record → cross-reference → confront) vs smuggler personal path (overhear → acquire proof → leverage → trade). +- **Memory system proposal:** Extend KG with InteractionMemory entries — who said what, when, player response, emotional register. Supports LD-09 (design for message history). + +**Nigel — Scenario validation:** +- 10-minute scenario trace: smuggler and detective play the same 10 minutes. +- **12 points of divergence, zero scripted branching.** All from knowledge graph differences interacting with tile-based world, fog, verbs, and dialogue. +- Specific divergence examples at dialogue: smuggler sees "Trade info" option detective doesn't have. Detective sees "Official inquiry" option smuggler doesn't have. Same NPC, same moment, different verbs. +- v0.1 passes second-playthrough test against all D-027 replayability criteria. + +#### Dialogue Consensus (Round 2) + +- **Walk-away has three phases.** Paula specifies. Extends Stig + Ozzie's Round 1 positions. No dissent. +- **KG records confrontation incompleteness.** Paula proposes. Powerful — enables future callbacks. No dissent. +- **Confrontation staging v0.1: proximity + audio dip + text only.** Ozzie descopes camera tighten. Pragmatic. No dissent. + +--- + +### Inventory (Updated — Now Confirmed for v0.1) + +**Tyre — Minimal implementation:** +- SmallVec<3> for smuggler inventory. Tiny memory footprint. +- Two verbs only: Take, Place. Carried items are world entities with a `CarriedBy` component. +- Contraband detection: NPC scan action checks player's carried items against knowledge graph (do they know it's contraband?). +- ~150 lines server-side for same-tile occupancy (shared implementation). + +**Dudley — Full server model:** +- `BTreeMap` per entity. +- Capacity: smuggler 4, detective 2. +- Carried items behind info boundary — private unless revealed. +- Integrates with existing two-phase verb computation: Phase 1 includes Take/Place verbs on eligible objects, Phase 2 filters by archetype capacity and knowledge. + +**Paula — Three items defined:** +1. Manifest copy (leverage proof) +2. Corridor access token (ring membership proof) +3. Personal comm log (recorded conversations as provable leverage) + +**Stig — Inventory UI:** +- Pocket icons, bottom-right, 40x40px. +- No empty slots. Icons appear only when items carried. +- Minimal screen real estate impact. + +#### Inventory Consensus (Round 2) + +- **Smuggler gets inventory in v0.1 (LD-05).** Unanimous acceptance. Tyre adapts with minimal implementation. +- **3 specific items defined.** Paula's three items. No dissent. +- **Carried items are private (info boundary).** Dudley proposes. Uncontested. +- **Pocket-icon UI, no empty slots.** Stig proposes. Uncontested. +- **Capacity differs by archetype.** Dudley: smuggler 4, detective 2. Aligns with character differentiation theme. + +#### Inventory Open Question + +- OQ-24: Tyre says SmallVec<3>, Dudley says BTreeMap with capacity 4 for smuggler. Implementation detail, but the capacity numbers differ (3 vs 4). Which is the v0.1 target? + +--- + +## Cross-Layer Interaction Chains + +Round 2's integration pass revealed how the layers compose into gameplay loops: + +### Chain 1: Eavesdropping Loop +``` +Movement (Careful mode, 0.5x speed, quiet footsteps) + → Positioning (find tile in D-018 medium audio range, face source) + → ListeningFocus accumulates (Dudley: stationary_ticks) + → Sound propagation triggers monologue (D-016) + → KG updated with overheard information + → New dialogue options may unlock (invisible until available) + → Confrontation option may appear on next interaction +``` + +### Chain 2: Fog Recognition Loop +``` +Fog entity detected (D-018 sound or sensor overlay) + → Cognitive delay begins (0.6-1.2s, exact TBD) + → Monologue fires DURING delay ("Those footsteps...") + → KG queried: do I know this entity? + → YES: D-033 color bleeds in, silhouette feature appears, identity overlay + → NO: Grey blob remains at approximate position + → Player decides: approach (movement) or observe (perception) or ignore +``` + +### Chain 3: Confrontation Chain +``` +Player observes contradiction (KG has conflicting facts) + → Monologue signals ("She said she was home, but I heard her in B-7") + → Player approaches NPC (movement → proximity ~3 tiles) + → NPC acknowledges approach (Ozzie: pre-dialogue beat) + → Player clicks Talk (interaction) + → Dialogue opens (Stig: bottom 20%, world visible) + → NEW: Confront option visible (italic, first-person: *"I saw you in B-7"*) + → Player selects → monologue beat (Paula: 1-2s, "No taking it back") + → Confrontation delivered → NPC Tier 2 animation + → Topics narrow, D-033 color may shift + → KG records confrontation + NPC response +``` + +### Chain 4: Smuggler Evidence Loop +``` +Smuggler overhears conversation (eavesdropping chain) + → KG records: cargo discrepancy exists + → Smuggler accesses terminal (interaction: "Access Manifests") + → Implant displays manifest data (no highlights — player reads raw) + → Player notices discrepancy (signal → answer) + → Physical action: Take manifest copy (inventory: 1 of 3 slots) + → Manifest is now PRIVATE (info boundary) — carried on person + → Risk: if scanned by security, contraband detected + → Leverage: can use manifest in confrontation dialogue +``` + +### Chain 5: Sprint Double-Take +``` +Player sprints through corridor (1 tile/tick) + → Passes through NPC interaction radius too fast to interact + → Routine monologue suppressed (Gestalt: interpretation, not data) + → BUT: anomaly detected (Ozzie: "sprint double-take") + → Delayed monologue: "Wait — was that Kael? At this hour?" + → Player decides: double back (movement) or keep going + → If double back: Careful approach → full recognition → interaction available +``` + +--- + +## v0.1 Minimum Viable Interaction Model + +Synthesized from all Round 2 responses: + +### Controls + +| Input | Action | Notes | +|-------|--------|-------| +| WASD | Move (default archetype speed) | Tile-based, Tween interpolated | +| Shift+WASD (or toggle) | Sprint (1 tile/tick) | Suppresses routine monologue. Anomaly monologue survives. | +| Ctrl+WASD (or toggle) | Careful (1 tile/3 ticks) | Quiet footsteps, enhanced monologue, eavesdrop positioning | +| Mouse movement | Facing direction | Client-side float, server gets octant | +| Left click | Default interaction / shoot | Context: cursor state determines action | +| Right click | World radial menu | 2 spokes: Observe, Insert | +| Scroll wheel | Zoom | Fog boundaries unchanged | +| WASD during dialogue | Walk away | 300ms fade, consequences | +| Shift (weapon mode) | Suppress interaction prompts | Ozzie proposal | + +### Cursor States (Araminta spec, LD-10) + +| State | Visual | Color | +|-------|--------|-------| +| Default | Four thin inward ticks, bloom | #c8d0e0 | +| Entity hover | Ticks expand, corner brackets, verb tooltip | D-033 color | +| Object hover | Ticks rotate 45° (X-shape) | #8b8ba0 / #e8c547 flagged | +| Weapon aim | Ticks extend, gap widens, 2px lines, NO bloom | #f0e8d8 | + +### Fog Layers (Araminta spec) + +| Layer | Treatment | +|-------|-----------| +| Clear (vision cone) | Soft gradient 3-4 tiles | +| Light fog (peripheral) | Desaturated 40-50%, Perlin noise 8-10s | +| Deep fog (explored) | Near-monochrome, zone temperature tint | +| Unexplored + maps | Wireframe #333340 | +| Unexplored, no maps | Solid #12141a | + +### Dialogue + +| Element | Spec | +|---------|------| +| Position | Bottom, max 20% height (LD-01), max-width TBD | +| Layout | NPC speech top, player options below, left-aligned | +| Portraits | None (NPC on screen) | +| Max options | 3 visible | +| Locked options | Invisible | +| Monologue | Floats above dialogue box (z-layer 7) | +| Walk-away | WASD, 300ms fade, 3-phase consequences | +| Confrontation | Same box, italic voice, pre-delivery monologue beat | + +### Smuggler Inventory + +| Element | Spec | +|---------|------| +| Capacity | 3-4 items (TBD: Tyre says 3, Dudley says 4) | +| UI | Pocket icons, bottom-right, 40x40px, no empty slots | +| Verbs | Take, Place | +| Info boundary | Carried items private | +| v0.1 items | Manifest copy, corridor access token, personal comm log | + +### Server Budget + +| System | Cost | Source | +|--------|------|--------| +| Interaction pipeline | <0.5ms/tick | Dudley | +| Fog rendering | <1ms/frame | Tyre | +| Overlay composition (4 modes) | ~7-11ms/frame client | Tyre | + +### Estimated Implementation + +| Component | Estimate | Source | +|-----------|----------|--------| +| Server systems | ~5-6 days | Tyre | +| Client systems | ~8-10 days | Tyre | + +--- + +## Summary Tables (Round 2) + +### New Consensus Points (Round 2) + +| # | Point | Agents | Source | +|---|-------|--------|--------| +| C-19 | Tile-based movement: unanimous (Nigel converts) | All | LD-03, Nigel scenario | +| C-20 | Three movement speeds in v0.1 (Walk/Sprint/Careful) | Gestalt, Dudley | Gestalt: Careful needed for eavesdrop | +| C-21 | MovementProfile per archetype (different defaults) | Dudley | LD-02 | +| C-22 | Same-tile occupancy system (Standing/Prone/Seated) | Tyre, Dudley | LD-03 provision | +| C-23 | Soft perception warning, not hard cap | Araminta, Lead | LD-04 | +| C-24 | Eavesdrop: passive + positioning enhances quality | Dudley (ListeningFocus), Lead | LD-07 | +| C-25 | Sprint double-take (anomaly monologue survives sprint) | Ozzie | Uncontested | +| C-26 | Smuggler inventory in v0.1: 3 specific items | Paula, Lead | LD-05 | +| C-27 | Carried items are private (info boundary) | Dudley | Uncontested | +| C-28 | Walk-away: 3-phase consequences (break → NPC reacts → KG records) | Paula | Extends R1 C-11 | +| C-29 | Confrontation staging v0.1: proximity + audio dip + text (camera deferred) | Ozzie | Descoped from R1 | +| C-30 | Max 3 dialogue response options | Stig | Uncontested | +| C-31 | Message history: design KG schema now, defer display | Stig, Paula | LD-09 | +| C-32 | 14 audio assets needed for v0.1 interactions | Ozzie | 6 new + 8 from D-038 | +| C-33 | Sprint reduces interpretation, not data (overlays render, monologue suppressed) | Gestalt | Uncontested as concept | +| C-34 | Nigel's scenario: 12 divergence points, zero scripted branching | Nigel | v0.1 replayability proof | + +### Disagreements Remaining (Need Lead Call) + +| # | Issue | Position A | Position B | Priority | +|---|-------|-----------|-----------|----------| +| D-02 | Entity menu format | Stig: vertical list (variable text, 1-4 options) | Araminta: spoke radial (new knowledge = new spoke grows) | Medium — affects interaction feel | +| D-07 | Sprint interaction suppression | Gestalt: physics handles it (too fast to click) | Dudley: explicit server suppression (clears buffer) | Low — same player experience, different implementation | +| D-08 | Cognitive delay values | Gestalt: 0.8-1.2s | Ozzie: 0.6s base / 0.3s urgent | Medium — affects fog recognition feel | +| D-09 | Dialogue max-width | Lead says max-width, not percentage | Exact pixel value not specified | Low — needs a number | + +### Open Questions (Updated for Round 2) + +**Resolved from Round 1:** + +| R1 # | Question | Resolution | +|-------|----------|------------| +| OQ-01 | Tile size? | Still open — not addressed in Round 2 | +| OQ-02 | Careful = Sneak? | Partially — Gestalt's "Careful" and Dudley's "Sneak" appear to be the same mode (slow, quiet, enhanced perception). Name TBD. | +| OQ-03 | Emergent positioning in tile system? | **Resolved** — same-tile occupancy (Tyre) + ListeningFocus (Dudley) | +| OQ-04 | Proximity thresholds? | Confirmed as two thresholds: acknowledge ~3 tiles (Ozzie), interact ~2 tiles (Stig) | +| OQ-05 | Interact through doorway? | Same-tile occupancy makes doorway interactions natural. LOS gates it. | +| OQ-06 | Weapon + sprint suppression layering? | Open — Gestalt and Dudley disagree on sprint suppression mechanism | +| OQ-07 | No insert = no prompts? | Open — not addressed | +| OQ-08 | False-positive fog: gameplay or atmospheric? | Open — not addressed | +| OQ-09 | Zone temperature: server or client? | Open — not addressed | +| OQ-10 | Sensor "different voice"? | Open — not addressed | +| OQ-11 | Mode-switch time? | Gestalt's hard cap rejected (LD-04). Soft degradation instead (Araminta). Switch time TBD. | +| OQ-12 | Insert reveals secrets? | Partially — Gestalt R1: public lattice only. Not contested. | +| OQ-13 | New data notification? | Open — not addressed | +| OQ-14 | Scan effectiveness per character? | Open — not addressed | +| OQ-15 | Pre-dialogue acknowledgment: server or client? | Open — deferred alongside camera tighten (post-v0.1?) | +| OQ-16 | Monologue beat timing in UI? | Partially — Paula: 1-2s. Stig's UI accommodates it. Exact flow TBD. | +| OQ-17 | Resume after walk-away? | Partially — Paula's 3-phase model implies not a simple resume. KG records incompleteness. | +| OQ-18 | Nigel's dialogue tiers vs D-041? | Open — not addressed | +| OQ-19 | Confrontation staging scope? | **Resolved** — Ozzie descopes to proximity + audio dip + text for v0.1 | +| OQ-20 | "No inventory" meaning? | **Resolved** — smuggler gets real inventory (LD-05). Detective gets 2 slots (Dudley). | +| OQ-21 | Archetype presentation: skin or different apps? | Open — not addressed in Round 2 | +| OQ-22 | Evidence matrix: verbs or framework? | Partially — Take/Place are verbs (Tyre). Observe/Photograph/Leave+Tell may be framework. | +| OQ-23 | Smuggler leverage without inventory? | **Resolved** — smuggler gets inventory (LD-05). | + +**New questions from Round 2:** + +| # | Question | Layer | +|---|----------|-------| +| OQ-24 | Inventory capacity: SmallVec<3> (Tyre) vs BTreeMap capacity 4 (Dudley)? | Inventory | +| OQ-25 | Mode name: "Careful" or "Sneak"? Observation focus or stealth focus? | Movement | +| OQ-26 | Gestalt's multiplicative formula: what are the actual modifier values? | Movement/Perception | +| OQ-27 | Araminta's soft degradation (scan-line interference): at what threshold? | Perception | +| OQ-28 | Paula's InteractionMemory KG extension: what's the schema? | Knowledge/Dialogue | +| OQ-29 | Dialogue max-width: what pixel value? (LD-01 says max-width, not percentage) | Dialogue | +| OQ-30 | Ozzie's 14 audio assets: who creates them? When in the sprint schedule? | Audio/Scope | + +--- + +*Round 2 compiled by Qatux from team lead summaries of all 8 participant responses. Cross-referenced against Round 1 notes for continuity. All attributions verified against source summaries.* + +--- +--- + +# Final Lead Decisions (Post-Round 2) + +**Date:** 2026-02-13 + +The lead reviewed all remaining disagreements and made final calls: + +### D-02 RESOLVED: Vertical list for entity menus, radial for world menu only. +- Lead rejects spoke radial for entities — items moving under cursor when knowledge changes is a moving goalpost (bad UX while aiming at an option). +- New items in a vertical list can be highlighted with a gradient glow effect in their background to signal "something new." +- Insert styling endorsed for the vertical list (Araminta's aesthetic applied to Stig's structure). +- Radial works for world menu because items don't vary much. +- **Stig's split wins, with Araminta's insert aesthetic applied to both.** + +### D-03 RESOLVED: Auto-pause SP confirmed. +- Tyre's architecture: auto-pause in single-player, overlay design for multiplayer readiness. + +### D-07 RESOLVED: Explicit sprint interaction suppression (Dudley's approach). +- Sprint clears interaction buffer on server. +- Rationale: mouse gymnastics to click during sprint = bad UX. Explicit suppression is cleaner and deterministic. + +### D-08 RESOLVED: Cognitive delay — tunable, start with Ozzie's values. +- 0.6s base, 0.3s urgent (when observe_anomaly triggers). +- Start here, adjust in playtesting. +- The urgent variant is endorsed as a nice touch. + +### NEW: Movement as stance toggle system. +- Stances: Sprint / Walk / Careful / Crouch / (Prone future) +- Toggle-based, NOT hold-to-activate. Player toggles between stances. +- Prone: toggle OUT only in normal play (must explicitly stand up). Future combat exception: "hit the deck" quick-entry. +- Replaces hold-modifier approach (Shift for sprint, Ctrl for careful) with a clean stance ladder. + +### ENDORSED compositions: +- Ozzie's movement speed perception gradient (sprint/walk/careful = tunnel vision / normal / heightened) +- Gestalt's interpretation vs data angle (sprint suppresses interpretation/monologue, not sensory data/overlays) +- Both compose: sprint = all overlays visible but zero monologue interpretation. + +### All Round 2 disagreements now resolved. + +| # | Issue | Resolution | Winner | +|---|-------|------------|--------| +| D-02 | Entity menu format | Vertical list (insert-styled) | Stig + Araminta aesthetic | +| D-03 | Implant pause SP | Auto-pause confirmed | Tyre | +| D-07 | Sprint suppression | Explicit server suppression | Dudley | +| D-08 | Cognitive delay | 0.6s base / 0.3s urgent, tunable | Ozzie | +| D-09 | Dialogue width | Max-width (not percentage) | Lead direction, value TBD | + +--- + +*Final resolutions recorded by Qatux. Workshop is now closed. Outcomes document produced at `docs/workshops/control-interaction/workshop-outcomes.md`.* diff --git a/docs/workshops/control-interaction/workshop-outcomes.md b/docs/workshops/control-interaction/workshop-outcomes.md new file mode 100644 index 000000000..b21bf58aa --- /dev/null +++ b/docs/workshops/control-interaction/workshop-outcomes.md @@ -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.* diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 5c3dd298e..98663e6fa 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -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), diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 9fd31e2cf..3251c94c1 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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, /// All entities visible to the observer (filtered by LOS + vision cone) pub entities: Vec, /// 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, + /// 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, + /// 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). diff --git a/server/src/main.rs b/server/src/main.rs index 7eb3c6f1b..0301e2f61 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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); diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 6de16c552..cd80ec780 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -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, registry: Res, 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, >, 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, ) { - 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, + ®istry, + ) + }) + .unwrap_or_default(); + let (mut entities, visible_ids) = filter_visible_entities(&geometry, ®istry, 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, + 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, } } diff --git a/server/src/perception/observer/tests.rs b/server/src/perception/observer/tests.rs index dd70ac97e..a84f1b351 100644 --- a/server/src/perception/observer/tests.rs +++ b/server/src/perception/observer/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::knowledge::types::KnowledgeState; use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; @@ -56,7 +57,7 @@ fn player_always_visible_in_snapshot() { let buffer = world.resource::(); let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); - assert_eq!(snapshot.version, 5); + assert_eq!(snapshot.version, 6); assert_eq!(snapshot.entities.len(), 1); assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible); @@ -632,3 +633,1094 @@ fn poi_interaction_gets_observe_first_priority() { assert_eq!(interaction.verbs[1].kind, VerbKind::Talk); assert_eq!(interaction.verbs[1].priority, 2); } + +// ----------------------------------------------------------------------- +// v6 field tests (Hoshe QA, Sprint 6 — #449) +// ----------------------------------------------------------------------- + +#[test] +fn snapshot_v6_fields_default_through_pipeline() { + // Until #417 (stance) and #424 (inventory) wire up the components, + // the observer system should produce Walk stance and empty inventory. + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist"); + + assert_eq!(snapshot.version, 6, "should be protocol v6"); + assert_eq!(snapshot.player_stance, MovementStance::Walk, "default stance is Walk"); + assert!(snapshot.player_inventory.is_empty(), "default inventory is empty"); +} + +#[test] +fn snapshot_v6_version_is_protocol_version() { + let mut world = setup_world(32, 32); + world.spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!( + snapshot.version, + crate::bridge::types::PROTOCOL_VERSION, + "snapshot version must match PROTOCOL_VERSION constant" + ); +} + +// ----------------------------------------------------------------------- +// Phase 2 verb filter tests (#422, D-057) +// ----------------------------------------------------------------------- + +#[test] +fn phase2_confront_injected_for_npc_with_knows_details() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC at close range, directly north in LOS + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has KnowsDetails confidence on NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + // Now at KnowsDetails (one step below Direct) + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Should have Talk, ExamineNpc, AND Confront (Phase 2 injected) + assert_eq!(interaction.verbs.len(), 3); + let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + assert!(confront.is_some(), "Confront should be injected for KnowsDetails+"); + assert_eq!(confront.unwrap().priority, 3); + assert_eq!(confront.unwrap().label, "Confront"); +} + +#[test] +fn phase2_no_confront_without_knows_details() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player only Suspects this NPC (below KnowsDetails threshold) + let mut kg = KnowledgeGraph::new(); + kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge { + last_known_position: Some(TilePosition::new(16, 15, 0)), + last_observed_tick: 50, + last_updated_tick: 50, + confidence: KnowledgeConfidence::Suspects, + source: crate::knowledge::KnowledgeSource::Background, + state: KnowledgeState::Active, + relationship: RelationshipState::Unknown, + known_attributes: std::collections::BTreeMap::new(), + }); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + assert!(confront.is_none(), "Confront should NOT appear for Suspects confidence"); +} + +#[test] +fn phase2_no_confront_at_mid_range() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC at mid range (distance 4, > CLOSE_RANGE=2) + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 12, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has KnowsDetails + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 12, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Mid range: only ExamineNpc, no Talk, no Confront + let confront = interaction.verbs.iter().find(|v| v.kind == VerbKind::Confront); + assert!(confront.is_none(), "Confront requires close range"); +} + +#[test] +fn phase2_contradiction_marks_interaction() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has contradicted knowledge about NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert!( + snapshot.nearby_interactions[0].contradicted, + "interaction should be marked contradicted" + ); +} + +#[test] +fn phase2_no_contradiction_for_active_knowledge() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + // Player has Active (normal) knowledge — no contradiction + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert!( + !snapshot.nearby_interactions[0].contradicted, + "interaction should NOT be contradicted for Active knowledge" + ); +} + +#[test] +fn phase2_smuggler_relabels_container_verbs() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Container at close range, north of player + let container = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Container, + )) + .id(); + registry.register(container); + + // Smuggler player + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + CharacterArchetype::Smuggler, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe" + let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); + let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); + let observe_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Observe); + assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move"); + assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash"); + assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged"); +} + +#[test] +fn phase2_detective_relabels_container_verbs() { + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let container = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Container, + )) + .id(); + registry.register(container); + + // Detective player (explicit) + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + CharacterArchetype::Detective, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); + let search_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Search); + assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan"); + assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag"); +} + +#[test] +fn phase2_default_archetype_is_detective() { + // When no CharacterArchetype component attached, defaults to Detective + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let container = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Container, + )) + .id(); + registry.register(container); + + // Player WITHOUT CharacterArchetype component + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + // Default = Detective labels + let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open); + assert_eq!(open_verb.unwrap().label, "Scan", "default archetype should use Detective labels"); +} + +#[test] +fn phase2_non_container_keeps_default_labels() { + // Readable objects should keep their default labels regardless of archetype + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let readable = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Readable, + )) + .id(); + registry.register(readable); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + CharacterArchetype::Smuggler, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let interaction = &snapshot.nearby_interactions[0]; + let read_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Read); + assert_eq!(read_verb.unwrap().label, "Read", "Readable labels unchanged for smuggler"); +} + +#[test] +fn phase2_object_type_carried_through_snapshot() { + // NearbyInteraction.object_type should be populated from Phase 1 + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let terminal = world + .spawn(( + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + ObjectType::Terminal, + )) + .id(); + registry.register(terminal); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert_eq!( + snapshot.nearby_interactions[0].object_type, + Some(ObjectType::Terminal), + "object_type should be carried through from Phase 1" + ); +} + +#[test] +fn phase2_npc_object_type_is_none() { + // NPCs should have object_type = None + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + registry.register(npc); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + assert_eq!( + snapshot.nearby_interactions[0].object_type, + None, + "NPC should have object_type=None" + ); +} + +// ----------------------------------------------------------------------- +// Sprint suppression end-to-end (#419 QA, D-055) +// ----------------------------------------------------------------------- + +#[test] +fn sprint_suppresses_interactions_through_full_pipeline() { + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC at close range, directly north in LOS + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + registry.register(npc); + + // Player in Sprint stance + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // NPC should be VISIBLE (sprint suppresses interpretation, not data per D-055) + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 1, "NPC should still be visible during sprint"); + // But interactions should be empty + assert!( + snapshot.nearby_interactions.is_empty(), + "sprint should suppress all nearby_interactions in final snapshot" + ); + // Stance should be Sprint in snapshot + assert_eq!(snapshot.player_stance, MovementStance::Sprint); +} + +#[test] +fn phase2_poi_with_confront_verb_order() { + // POI NPC with KnowsDetails: ExamineNpc(1), Talk(2), Confront(3) + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.observe_entity_leaving_los(&npc_sid, 60); + kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.nearby_interactions.len(), 1); + let verbs = &snapshot.nearby_interactions[0].verbs; + assert_eq!(verbs.len(), 3, "POI+KnowsDetails: ExamineNpc + Talk + Confront"); + // POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3 + assert_eq!(verbs[0].kind, VerbKind::ExamineNpc); + assert_eq!(verbs[0].priority, 1); + assert_eq!(verbs[1].kind, VerbKind::Talk); + assert_eq!(verbs[1].priority, 2); + assert_eq!(verbs[2].kind, VerbKind::Confront); + assert_eq!(verbs[2].priority, 3); +} + +// ----------------------------------------------------------------------- +// Inventory through observer pipeline (#424 QA, D-065) +// ----------------------------------------------------------------------- + +#[test] +fn carried_item_appears_in_snapshot_inventory() { + // D-065: player_inventory populated via collect_inventory_for through full pipeline + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + let player_sid = registry.register(player); + + // Item carried by player (no TilePosition — in inventory) + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + registry.register(item); + + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.player_inventory.len(), 1, "carried item should appear in snapshot"); + assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); + assert_eq!(snapshot.player_inventory[0].slot, 0); +} + +#[test] +fn carried_item_not_in_visible_entities() { + // D-065 info boundary: carried items have no TilePosition, so they + // must NOT appear in the visible entity list (spatial queries skip them). + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + let player_sid = registry.register(player); + + // Item in inventory: has CarriedBy but NO TilePosition + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Corridor Access Token".into()), + InventorySlot(1), + )) + .id(); + registry.register(item); + + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + // Only the player should be in visible entities — carried item has no TilePosition + assert_eq!( + snapshot.entities.len(), + 1, + "carried item without TilePosition must not appear in visible entities" + ); + assert!(matches!(snapshot.entities[0].kind, EntityKind::Player)); + // But it IS in the inventory + assert_eq!(snapshot.player_inventory.len(), 1); +} + +#[test] +fn multiple_carried_items_sorted_in_snapshot() { + // D-065: 3 v0.1 items, verify sorting by slot through pipeline + use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing::default(), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + )) + .id(); + let player_sid = registry.register(player); + + // Spawn 3 v0.1 items in reverse slot order + for (slot, name) in [(2, "Personal Comm Log"), (0, "Manifest Copy"), (1, "Corridor Access Token")] { + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName(name.into()), + InventorySlot(slot), + )) + .id(); + registry.register(item); + } + + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + assert_eq!(snapshot.player_inventory.len(), 3); + assert_eq!(snapshot.player_inventory[0].slot, 0); + assert_eq!(snapshot.player_inventory[0].name, "Manifest Copy"); + assert_eq!(snapshot.player_inventory[1].slot, 1); + assert_eq!(snapshot.player_inventory[1].name, "Corridor Access Token"); + assert_eq!(snapshot.player_inventory[2].slot, 2); + assert_eq!(snapshot.player_inventory[2].name, "Personal Comm Log"); +} + +// ----------------------------------------------------------------------- +// Sprint anomaly detection tests (#428, D-055) +// ----------------------------------------------------------------------- + +#[test] +fn sprint_past_contradicted_npc_queues_anomaly() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // NPC in LOS, directly north + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Player has Contradicted knowledge about the NPC + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + // Anomaly should be queued + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending(), "contradicted NPC while sprinting should queue anomaly"); +} + +#[test] +fn walk_past_contradicted_npc_does_not_queue_anomaly() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Walk), // Walking, not sprinting + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(!queue.has_pending(), "walking past contradicted NPC should NOT queue anomaly"); +} + +#[test] +fn sprint_past_active_npc_does_not_queue_anomaly() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + // Active knowledge (not contradicted) + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(!queue.has_pending(), "sprint past Active NPC should NOT queue anomaly"); +} + +#[test] +fn sprint_anomaly_not_queued_when_already_pending() { + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + // Pre-fill anomaly queue with an existing pending entry + let mut queue = SprintAnomalyQueue::default(); + queue.push_anomaly(999, 0); // Different entity, already pending + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + queue, + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + // Queue should still have the original entry (first-in wins) + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending()); + // The original entity_id should be preserved (not overwritten) + // We can't directly inspect the entity_id, but we can verify via take_ready +} + +#[test] +fn sprint_anomaly_without_queue_component_no_crash() { + use crate::simulation::stance::Stance; + + // Player without SprintAnomalyQueue should still work (backward compat) + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + // No SprintAnomalyQueue — should not crash + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + // Should run without panicking + run_observer_pipeline(&mut world); + + let buffer = world.resource::(); + assert!(buffer.snapshot.is_some(), "snapshot should still be produced"); +} + +#[test] +fn sprint_anomaly_npc_visible_but_interactions_suppressed() { + // D-055: sprint suppresses interpretation, not sensory data. + // The NPC should be visible AND queue an anomaly, but interactions empty. + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + let npc = world + .spawn(( + crate::npc::Npc, + TilePosition::new(16, 15, 0), + crate::simulation::interaction::Interactable, + )) + .id(); + let npc_sid = registry.register(npc); + + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50); + kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_full_pipeline(&mut world); + + let buffer = world.resource::(); + let snapshot = buffer.snapshot.as_ref().unwrap(); + + // NPC should be visible (sprint doesn't suppress visibility) + let npcs: Vec<_> = snapshot + .entities + .iter() + .filter(|e| matches!(e.kind, EntityKind::Npc)) + .collect(); + assert_eq!(npcs.len(), 1, "NPC should be visible during sprint"); + + // Interactions should be empty (sprint suppression) + assert!(snapshot.nearby_interactions.is_empty(), "sprint suppresses interactions"); + + // Anomaly should be queued + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending(), "anomaly should be queued despite interaction suppression"); +} + +#[test] +fn sprint_anomaly_multiple_contradicted_npcs_only_first_queued() { + // D-055: first-in wins — only the first Contradicted entity per scan is queued + use crate::simulation::monologue::SprintAnomalyQueue; + use crate::simulation::stance::Stance; + + let mut world = setup_world(32, 32); + let mut registry = EntityRegistry::new(0); + + // Two contradicted NPCs in LOS + let npc1 = world + .spawn((crate::npc::Npc, TilePosition::new(16, 14, 0))) + .id(); + let npc1_sid = registry.register(npc1); + + let npc2 = world + .spawn((crate::npc::Npc, TilePosition::new(16, 12, 0))) + .id(); + let npc2_sid = registry.register(npc2); + + // Player has Contradicted knowledge about BOTH NPCs + let mut kg = KnowledgeGraph::new(); + kg.observe_entity(npc1_sid, TilePosition::new(16, 14, 0), 50); + kg.entities.get_mut(&npc1_sid).unwrap().state = KnowledgeState::Contradicted; + kg.observe_entity(npc2_sid, TilePosition::new(16, 12, 0), 50); + kg.entities.get_mut(&npc2_sid).unwrap().state = KnowledgeState::Contradicted; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + Facing(FacingDirection::North), + kg, + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + Stance(MovementStance::Sprint), + SprintAnomalyQueue::default(), + )) + .id(); + registry.register(player); + world.insert_resource(registry); + + run_observer_pipeline(&mut world); + + // Exactly one anomaly should be queued (first-in wins, break after first) + let mut query = world.query::<&SprintAnomalyQueue>(); + let queue = query.single(&world).unwrap(); + assert!(queue.has_pending(), "one anomaly should be queued"); +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index be0670ac7..b3a9fee05 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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, mut time: ResMut, mut commands: Commands, - player_query: Query<(Entity, &TilePosition), With>, + registry: Res, + mut player_query: Query< + (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + With, + >, + 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, + ®istry, + &player_query, + &inventory_items, + target_entity_id, + ); + } + Some("Place") => { + handle_place( + &mut commands, + ®istry, + &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>, + player_query: &mut Query< + (Entity, &TilePosition, Option<&mut Stance>, Option<&mut PlayerMoveCooldown>), + With, + >, 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, + >, + inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + target_entity_id: Option, +) { + 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::() + .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, + >, + target_entity_id: Option, +) { + 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::() + .remove::() + .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::(); 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::(); world.resource_mut::().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::(); world.resource_mut::().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::(); world.resource_mut::().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::(); 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::(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::(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + PlayerMoveCooldown::default(), + )); + + world.resource_mut::().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::(); + + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + Stance::default(), + PlayerMoveCooldown::default(), + )); + + world.resource_mut::().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::(); + + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some(), "first move should succeed"); + + // Remove MoveIntent (simulating validate_movement consuming it) + world.entity_mut(player).remove::(); + + // Tick 0 again: move north — should be throttled (cooldown) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_none(), "second move should be throttled"); + + // Tick 0 again: move north — should succeed (cooldown elapsed) + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(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::(); + + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + world.entity_mut(player).remove::(); + + // Second move — sprint allows every tick + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(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::(); + + 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::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(player).is_some()); + world.entity_mut(player).remove::(); + + // Second move immediately — no throttle without components + world.resource_mut::().push(PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }); + schedule.run(&mut world); + assert!(world.get::(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::(); + + // Spawn player and register + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().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::().register(item); + + // Issue Take verb + world.resource_mut::().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::(item).is_none(), "item should leave the ground"); + let carried = world.get::(item).expect("item should have CarriedBy"); + assert_eq!(carried.0, player_sid); + let slot = world.get::(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::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().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::().register(item); + + // Issue Place verb + world.resource_mut::().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::(item).expect("item should be on ground"); + assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); + assert!(world.get::(item).is_none(), "CarriedBy removed"); + assert!(world.get::(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::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().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::().register(item2); + + world.resource_mut::().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::(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::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().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::().register(item); + + world.resource_mut::().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::(item).is_some(), "item stays on ground"); + assert!(world.get::(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::(); + + let player = world + .spawn((PlayerCharacter, TilePosition::new(5, 5, 0))) + .id(); + let player_sid = world.resource_mut::().register(player); + + let item = world + .spawn(( + TilePosition::new(5, 4, 0), + ItemName("Manifest Copy".into()), + )) + .id(); + let item_sid = world.resource_mut::().register(item); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(process_player_input); + + // Step 1: Take + world.resource_mut::().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::(item).is_none(), "item off ground after Take"); + assert_eq!(world.get::(item).unwrap().0, player_sid); + assert_eq!(world.get::(item).unwrap().0, 0); + + // Step 2: Place + world.resource_mut::().push(PlayerInput { + tick: 1, + action: PlayerAction::Interact { + target_entity_id: Some(item_sid.0), + verb: Some("Place".into()), + }, + }); + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + let pos = world.get::(item).expect("item back on ground after Place"); + assert_eq!(*pos, TilePosition::new(5, 5, 0), "placed at player position"); + assert!(world.get::(item).is_none(), "CarriedBy removed after Place"); + assert!(world.get::(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::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().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::(); + + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0))); + + world.resource_mut::().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 + } } diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index cb5d2935c..e802a5303 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -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, >, registry: Res, interactables: Query< - (Entity, &TilePosition, Option<&Npc>), + (Entity, &TilePosition, Option<&Npc>, Option<&ObjectType>), (With, Without), >, ) { - 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"); + } } diff --git a/server/src/simulation/inventory.rs b/server/src/simulation/inventory.rs new file mode 100644 index 000000000..675eb6a49 --- /dev/null +++ b/server/src/simulation/inventory.rs @@ -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 { + 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 { + let mut result: Vec = 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 { + 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::(); + 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 = (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::().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::().register(player); + + let item = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + world.resource_mut::().register(item); + + // Use system_state for proper Query access + let mut system_state = bevy_ecs::system::SystemState::<( + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + Res, + )>::new(&mut world); + let (items_query, registry) = system_state.get(&world); + + let inventory = collect_inventory_for(player_sid, &items_query, ®istry); + 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::().register(player); + + let other = world.spawn_empty().id(); + let other_sid = world.resource_mut::().register(other); + + // Player's item + let item1 = world + .spawn(( + CarriedBy(player_sid), + ItemName("Manifest Copy".into()), + InventorySlot(0), + )) + .id(); + world.resource_mut::().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::().register(item2); + + let mut system_state = bevy_ecs::system::SystemState::<( + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + Res, + )>::new(&mut world); + let (items_query, registry) = system_state.get(&world); + + let inventory = collect_inventory_for(player_sid, &items_query, ®istry); + 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::().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::().register(item); + } + + let mut system_state = bevy_ecs::system::SystemState::<( + Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>, + Res, + )>::new(&mut world); + let (items_query, registry) = system_state.get(&world); + + let inventory = collect_inventory_for(player_sid, &items_query, ®istry); + 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::().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); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index c78854d78..92c04a52b 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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; diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 4e2421542..3f53c8b19 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -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, +} + +#[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 { + 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, + mut rng: ResMut, + mut query: Query< + (&mut SprintAnomalyQueue, &mut MonologueBuffer, &mut MonologueState), + With, + >, +) { + 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::(); + 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::().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::().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::().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::().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::().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::().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::().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" + ); + } } diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index d8943774b..13ba4a7a6 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -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>, - mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>, - stationary: Query<(Entity, &TilePosition), Without>, + mut movers: Query<(Entity, &MoveIntent, &mut TilePosition, Option<&TilePresence>)>, + stationary: Query<(Entity, &TilePosition, Option<&TilePresence>), Without>, ) { 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::(); } return; }; - // Collect tiles occupied by stationary entities (no MoveIntent) - let mut occupied: HashMap = 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::(); } @@ -570,4 +603,304 @@ mod tests { ); assert!(world.get::(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::(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::(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::(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::(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::(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::(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::(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::(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::(standing).unwrap(), + target, + "Standing should share tile with Fixture + Prone" + ); + assert_eq!( + *world.get::(seated).unwrap(), + target, + "Seated should share tile with Fixture + Prone + Standing" + ); + } } diff --git a/server/src/simulation/stance.rs b/server/src/simulation/stance.rs new file mode 100644 index 000000000..302318172 --- /dev/null +++ b/server/src/simulation/stance.rs @@ -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::(entity).unwrap(); + assert_eq!(stored.default_stance, MovementStance::Walk); + + let stance = world.get::(entity).unwrap(); + assert_eq!(stance.0, MovementStance::Walk); + } +} diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index ab88147f8..69e52ddc5 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -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, diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3373445b9..3e980b3d7 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -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, diff --git a/server/tests/game_loop.rs b/server/tests/game_loop.rs index 721aefc6a..58df823a2 100644 --- a/server/tests/game_loop.rs +++ b/server/tests/game_loop.rs @@ -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); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 862e2a8b8..af7b8c352 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -27,6 +27,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> 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, diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 06063c043..5b6418ec8 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -16,6 +16,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> 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, + visible_tiles: Vec, + nearby_interactions: Vec, + current_monologue: Option, + } + + 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 = (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)); +}