Compare commits
@@ -74,35 +74,35 @@ If the diff is empty, report "No changes to review" and stop.
|
||||
|
||||
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||
2. Read source files directly from the branch worktree (see below).
|
||||
2. Read source files directly from the team directory (see below).
|
||||
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews.
|
||||
|
||||
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
||||
worktrees. Each team branch is checked out at:
|
||||
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
|
||||
is checked out in its own directory at:
|
||||
|
||||
```
|
||||
/var/mnt/data/projects/settled-reach/<branch>/
|
||||
```
|
||||
|
||||
For example, the `copy` branch lives at:
|
||||
For example, the `copy` team directory is at:
|
||||
```
|
||||
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
|
||||
```
|
||||
|
||||
**All reviewer agents** (regardless of Bash access) should read source files
|
||||
from the worktree path using the Read tool. This is more reliable than
|
||||
from the team directory using the Read tool. This is more reliable than
|
||||
`git show origin/<branch>:<path>` because:
|
||||
- All agents have Read access (no Bash dependency)
|
||||
- Files are always the actual branch checkout (no stale cache)
|
||||
- No risk of accidentally reading from main's working directory
|
||||
|
||||
When constructing reviewer prompts, tell agents to read files from the
|
||||
worktree path. Example instruction for agents:
|
||||
team directory. Example instruction for agents:
|
||||
|
||||
```
|
||||
Read the changed files from the branch worktree. The branch is checked
|
||||
Read the changed files from the team directory. The branch is checked
|
||||
out at: /var/mnt/data/projects/settled-reach/<branch>/
|
||||
|
||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||
@@ -110,8 +110,8 @@ use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal
|
||||
```
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||
worktree (they're identical to main, but using the worktree path keeps
|
||||
agents grounded in the correct directory).
|
||||
directory (they're identical to main, but using the team directory path
|
||||
keeps agents grounded in the correct location).
|
||||
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**All agents read from worktree paths.** Each branch is checked out at:
|
||||
**All reviewer agents read from team directories.** Each team branch is
|
||||
checked out in its own directory at:
|
||||
`/var/mnt/data/projects/settled-reach/<branch>/`
|
||||
|
||||
Tell every reviewer agent to read source files from the worktree using the
|
||||
Read tool. Include the worktree base path and a list of changed files in
|
||||
every prompt. Do NOT rely on `git show` or paste file contents — agents
|
||||
can read directly from the worktree.
|
||||
Tell every reviewer agent to read source files from the team directory
|
||||
using the Read tool. Include the directory path and a list of changed
|
||||
files in every prompt. Do NOT rely on `git show` or paste file contents —
|
||||
agents can read directly from the directory.
|
||||
|
||||
Note: cross-directory reading is only permitted for review agents spawned
|
||||
from the `main` team. Team agents must stay within their own directory.
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
**Hoshe (Code Quality)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the team directory, then review for:
|
||||
- Correctness and bug risks
|
||||
- Error handling gaps
|
||||
- Test coverage (are new features tested?)
|
||||
@@ -25,8 +29,8 @@ can read directly from the worktree.
|
||||
|
||||
**Tyre (Architecture)**
|
||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the team directory
|
||||
first, then review for:
|
||||
- Architectural consistency with project decisions
|
||||
- API/interface design quality
|
||||
@@ -38,8 +42,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- Formatting consistency (markdown, file naming, frontmatter)
|
||||
- Broken references or links
|
||||
- Spelling and grammar
|
||||
@@ -48,9 +52,9 @@ can read directly from the worktree.
|
||||
|
||||
**Paula (Narrative Depth)**
|
||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
- Prompt: Provide the team directory path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Paula to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
files from the team directory using the Read tool, then review for:
|
||||
- Narrative quality and character voice consistency
|
||||
- Whether dialogue and monologue feel authentic to the characters
|
||||
- Consequences and stakes — do choices carry weight?
|
||||
@@ -59,9 +63,9 @@ can read directly from the worktree.
|
||||
|
||||
**Miri (World Consistency)**
|
||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
- Prompt: Provide the team directory path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Miri to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
files from the team directory using the Read tool, then review for:
|
||||
- Lore accuracy — do facts match established setting?
|
||||
- Internal consistency across files
|
||||
- IP originality — nothing should read as a copy from another franchise
|
||||
@@ -72,8 +76,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- File format and naming conventions
|
||||
- Asset organization and directory structure
|
||||
- Missing or broken references in scene/resource files
|
||||
@@ -81,7 +85,7 @@ can read directly from the worktree.
|
||||
|
||||
**Araminta (Art Direction)**
|
||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Araminta to read the style guide and relevant design docs from the
|
||||
worktree first, then review for:
|
||||
- Visual consistency with the established style guide
|
||||
@@ -94,8 +98,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- File format and naming conventions
|
||||
- Audio asset organization and directory structure
|
||||
- Missing or broken references
|
||||
@@ -103,8 +107,8 @@ can read directly from the worktree.
|
||||
|
||||
**Ozzie (Player Experience)**
|
||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the worktree using the Read tool, then
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the team directory using the Read tool, then
|
||||
review for:
|
||||
- Emotional impact — does the audio enhance the moment?
|
||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||
|
||||
@@ -154,13 +154,12 @@ 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:
|
||||
**IMPORTANT — relative paths only:** Each team works in its own directory
|
||||
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the working directory. 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.
|
||||
paths that navigate outside (`../sibling-dir/...`).
|
||||
Agents must stay within their team's working directory.
|
||||
|
||||
Key requirements per file:
|
||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||
@@ -193,7 +192,20 @@ tooling/db/ticket sprint assign <ticket_id> <sprint_id>
|
||||
The sprint stays in `planning` status until explicitly activated via
|
||||
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
|
||||
|
||||
### 8. Present summary
|
||||
### 8. Commit and push
|
||||
|
||||
Stage the briefing files and any other changes (db backup, closed tickets),
|
||||
then commit and push so worktree branches can pull the planning artifacts:
|
||||
|
||||
```bash
|
||||
git add docs/sprints/sprint-N/
|
||||
make db-backup
|
||||
git add docs/backups/settledreach.db.backup
|
||||
git commit -m "chore(meta): plan Sprint N: Theme"
|
||||
git push
|
||||
```
|
||||
|
||||
### 9. Present summary
|
||||
|
||||
Output:
|
||||
- Sprint number, theme, and goal
|
||||
@@ -201,3 +213,4 @@ Output:
|
||||
- Carry-over count
|
||||
- Open questions that need early resolution
|
||||
- Files written
|
||||
- Commit pushed to main
|
||||
|
||||
@@ -290,6 +290,11 @@ Task(
|
||||
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
|
||||
within the current working directory. Do NOT navigate to
|
||||
parent or sibling directories. Do NOT follow .git pointers
|
||||
to other directories.
|
||||
|
||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead. No exceptions.
|
||||
|
||||
@@ -6,6 +6,33 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.23] — 2026-03-04
|
||||
|
||||
### Added
|
||||
- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with per-tile type data alongside walkability (#576, D-094)
|
||||
- Location YAML tile format — hand-author tiles as string arrays (F/W/V/R characters), loaded into WalkabilityMap on production startup (#577)
|
||||
- Chunk streaming system — ChunkLoadRadius and cadence-gated load/unload around player position, v0.1 covers full district (#578, D-012)
|
||||
- EngagementRecord component — per-NPC observation time, conversation count, and monologue trigger count tracked by perception/dialogue/monologue systems (#570)
|
||||
- MovementHistoryBuffer resource — 3000-tick ring buffer of player positions with co-presence proximity query (#571)
|
||||
- Storyteller lifecycle rules — single activation per session, no concurrency, terminal resolution constants (#572)
|
||||
- Storyteller activation_pass() — gate/proximity/engagement scoring/routing/module selection/TriangleActivatedEvent on 10-tick cadence (#579)
|
||||
- Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580)
|
||||
- Debug console client — tilde-toggle UI panel with command input, output log, settings toggle, and full DebugCommandKind dispatch via protocol v18 (#581)
|
||||
- Entity-bound dialogue speaker colors — NPC colors assigned by entity ID (not screen position) with per-conversation lifecycle and round-robin palette (#573)
|
||||
- Sova Transit District tile maps — 5 locations authored: The Terminal (44×28), The Last Shift (34×22), Maintenance Corridors (58×6), Gate Ground (40×34), Gate Gallery (32×10) (#582, #583)
|
||||
|
||||
### Fixed
|
||||
- LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584)
|
||||
- LOS boundary walls client — BoundaryWall tiles render through fog without marking explored, 4 new fog tests verify lifecycle (#585)
|
||||
- Entity renderer test failures — updated 7 stale ColorRect/position assertions for Sprite2D migration, fixed SoundIndicatorRenderer class cache (#574)
|
||||
- Dialogue speaker color contrast — re-enforce contrast floor after desaturation for passive (overheard) lines
|
||||
- PROTOCOL_VERSION 17 → 18 mismatch — client rejected every server snapshot
|
||||
- Debug console D-088 pause — sim now pauses while console is open, matching dialogue/settings overlay behavior
|
||||
- Debug console settings toggle reads live state instead of ConfigFile, preventing checkbox divergence
|
||||
|
||||
### Changed
|
||||
- PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant)
|
||||
|
||||
## [v0.1.22] — 2026-03-03
|
||||
|
||||
### Added
|
||||
@@ -167,6 +194,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||
|
||||
### Changed
|
||||
- Team boundary framing — replaced worktree-centric language with `$WORKTREE_TEAM` env var identity across CLAUDE.md and skills (sprint-start, sprint-plan, pr-review) to prevent agents from following `.git` pointers across boundaries
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||
|
||||
@@ -27,14 +27,15 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
### Worktree boundaries
|
||||
### Team boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
|
||||
|
||||
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
|
||||
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
|
||||
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
|
||||
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
|
||||
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -60,6 +61,13 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### Testing preferences
|
||||
|
||||
- **Prefer live Gauntlet testing over mocks.** For visual tests and rendering verification, use the full client/server pipeline (`--test-mode` + `SR_LIVE=1`) instead of TestHarness mocks. The Gauntlet test world produces production-identical data. Mocks can mask rendering bugs by taking different code paths.
|
||||
- **Gauntlet rooms are immutable.** Never modify existing rooms — new systems get new rooms. This ensures StableId determinism and fixture stability.
|
||||
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
|
||||
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=27 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=28 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"]
|
||||
@@ -26,6 +26,7 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||
[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"]
|
||||
[ext_resource type="PackedScene" uid="uid://b2ndm9rvx8cqp" path="res://ui/debug_console.tscn" id="27_debug_console"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -194,3 +195,6 @@ layer = 30
|
||||
|
||||
; #257: Loading screen — full-screen overlay during save/load round-trip
|
||||
[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")]
|
||||
|
||||
; #581: Debug console — tilde key toggles, bottom 40% of screen
|
||||
[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")]
|
||||
|
||||
@@ -161,6 +161,14 @@ func update_from_state() -> void:
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||
# #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly,
|
||||
# but NOT in visible_positions so they don't persist as explored memory.
|
||||
for pos in GameState.boundary_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
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||
visibility_texture.update(_vis_image)
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
|
||||
|
||||
# v2 fields (D-015, D-031)
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
||||
@@ -81,6 +82,11 @@ var rng_seed: Variant = null
|
||||
# One-shot: consumed by main.gd after display, then set back to null.
|
||||
var save_result: Variant = null
|
||||
|
||||
# v18 fields (#580): debug console response from server.
|
||||
# {command: String, text: String, success: bool} or null.
|
||||
# One-shot: consumed by main.gd and forwarded to DebugConsole, then set to null.
|
||||
var debug_response: Variant = null
|
||||
|
||||
# #257: Pending load path — set by main menu "Load Game" selection.
|
||||
# main.gd sends LOAD_GAME on startup if non-empty, then clears this field.
|
||||
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
|
||||
@@ -311,6 +317,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
save_result = null
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
debug_response = snapshot.debug_response
|
||||
else:
|
||||
debug_response = null
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
@@ -336,17 +348,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
# Derives visible_positions when not explicitly provided (real server mode).
|
||||
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
visible_positions.clear()
|
||||
boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
visibility_sectors[pos] = vtile.visibility
|
||||
if not has_explicit_positions:
|
||||
visibility_sectors[pos] = vis_sector
|
||||
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
|
||||
# explored — they don't update the player's exploration memory when they leave LOS.
|
||||
if vis_sector == "BoundaryWall":
|
||||
boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
visible_positions[pos] = true
|
||||
|
||||
@@ -24,6 +24,7 @@ enum Action {
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
@@ -416,6 +416,8 @@ static func action_enum_to_wire(action: int) -> String:
|
||||
return "SaveGame" # #554: F5 quicksave (D-085)
|
||||
InputMapper.Action.LOAD_GAME:
|
||||
return "LoadGame" # #554: F6 quickload (D-085)
|
||||
InputMapper.Action.DEBUG_COMMAND:
|
||||
return "DebugCommand" # #581: debug console command dispatch
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
+24
-2
@@ -22,6 +22,7 @@ extends Node2D
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
@@ -122,6 +123,16 @@ func _ready() -> void:
|
||||
_router.register("conversation_ended", _consume_conversation_ended)
|
||||
_router.register("dialogue_response", _consume_dialogue_response)
|
||||
_router.register("save_result", _consume_save_result)
|
||||
_router.register("debug_response", _consume_debug_response)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
|
||||
if settings_dialog and debug_console:
|
||||
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
|
||||
|
||||
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
|
||||
if debug_console:
|
||||
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -351,7 +362,8 @@ func _consume_dialogue() -> void:
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", [])
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
@@ -381,11 +393,13 @@ func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
|
||||
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
@@ -413,6 +427,14 @@ func _consume_save_result() -> void:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func _consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
|
||||
@@ -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 = 17
|
||||
const PROTOCOL_VERSION: int = 18
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -244,6 +244,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"error": raw_save.get("error"),
|
||||
}
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
# {command: String, text: String, success: bool}
|
||||
var debug_response: Variant = null
|
||||
var raw_debug: Variant = raw.get("debug_response")
|
||||
if raw_debug is Dictionary:
|
||||
debug_response = {
|
||||
"command": str(raw_debug.get("command", "")),
|
||||
"text": str(raw_debug.get("text", "")),
|
||||
"success": bool(raw_debug.get("success", false)),
|
||||
}
|
||||
|
||||
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
||||
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||
@@ -320,6 +331,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"examine_result": examine_result,
|
||||
"player_knowledge": player_knowledge,
|
||||
"save_result": save_result,
|
||||
"debug_response": debug_response,
|
||||
"stationary_ticks": stationary_ticks,
|
||||
"zone_id": zone_id,
|
||||
}
|
||||
|
||||
@@ -64,11 +64,17 @@ func _setup_tileset() -> void:
|
||||
tile_set = ts
|
||||
|
||||
# Update tiles from snapshot data
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)}
|
||||
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
||||
# NOT the Godot scene z_index (which controls render order within a floor).
|
||||
# This node only renders floor-level 0. Higher floor levels will be handled
|
||||
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
|
||||
#
|
||||
# BoundaryWall tiles (#585): visibility="BoundaryWall" tiles (wall tiles 1 step beyond
|
||||
# LOS boundary) are rendered normally here — they have a "type" field from protocol.gd
|
||||
# so they composite correctly under the fog shader. The fog/exploration exemption is
|
||||
# handled in fog_state.gd (VIS_FORWARD without EXP_VISIBLE) and game_state.gd
|
||||
# (boundary_positions not visible_positions). No special handling needed in this method.
|
||||
func update_tiles(tiles: Array) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -167,8 +167,8 @@ func test_entity_terrain_uses_object_color() -> void:
|
||||
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
|
||||
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[70] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
var node = renderer.entity_nodes[70] as Sprite2D
|
||||
assert_that(node.self_modulate).override_failure_message(
|
||||
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
||||
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
@@ -181,8 +181,8 @@ func test_entity_player_color_regardless_of_sector() -> void:
|
||||
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[80] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
var node = renderer.entity_nodes[80] as Sprite2D
|
||||
assert_that(node.self_modulate).override_failure_message(
|
||||
"Player color must be constant regardless of visibility sector"
|
||||
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
# Alpha should still be dimmed for Peripheral
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
## Sprint 23 #573: dialogue speaker color binding tests.
|
||||
##
|
||||
## Verifies entity-ID-bound speaker color assignment in dialogue_box.gd:
|
||||
## round-robin palette allocation, same-entity reuse, conversation-end reset,
|
||||
## and fallback behavior when no entity ID is provided.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestDialogueSpeakerColors
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSpeakerColors: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# -- _assign_npc_color: round-robin assignment ---------------------------------
|
||||
|
||||
func test_assign_npc_color_returns_palette_color() -> void:
|
||||
## First call for an entity ID should return a color from the NPC palette.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color: Color = box._assign_npc_color(100)
|
||||
assert_that(color).override_failure_message(
|
||||
"_assign_npc_color must return a non-default color for a valid entity ID (#573)"
|
||||
).is_not_equal(box._speech_color)
|
||||
|
||||
|
||||
func test_assign_npc_color_same_entity_returns_same_color() -> void:
|
||||
## Repeated calls for the same entity ID must return the same color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color1: Color = box._assign_npc_color(200)
|
||||
var color2: Color = box._assign_npc_color(200)
|
||||
assert_that(color1).override_failure_message(
|
||||
"_assign_npc_color must return the same color for the same entity ID (#573)"
|
||||
).is_equal(color2)
|
||||
|
||||
|
||||
func test_assign_npc_color_different_entities_get_different_colors() -> void:
|
||||
## Different entity IDs should get different colors (within palette size).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color1: Color = box._assign_npc_color(300)
|
||||
var color2: Color = box._assign_npc_color(301)
|
||||
assert_that(color1).override_failure_message(
|
||||
"Different entity IDs must get different palette colors (#573)"
|
||||
).is_not_equal(color2)
|
||||
|
||||
|
||||
func test_assign_npc_color_negative_id_returns_speech_color() -> void:
|
||||
## Negative entity ID (no entity) should fall back to _speech_color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color: Color = box._assign_npc_color(-1)
|
||||
assert_that(color).override_failure_message(
|
||||
"_assign_npc_color(-1) must return _speech_color fallback (#573)"
|
||||
).is_equal(box._speech_color)
|
||||
|
||||
|
||||
# -- Color registry cleared on conversation end --------------------------------
|
||||
|
||||
func test_color_registry_cleared_on_conversation_end() -> void:
|
||||
## After hide_dialogue(), the color registry must be empty so next
|
||||
## conversation starts fresh (avoids palette exhaustion).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [], 400)
|
||||
assert_bool(box._npc_entity_colors.has(400)).override_failure_message(
|
||||
"Entity color should be registered during conversation (#573)"
|
||||
).is_true()
|
||||
box.hide_dialogue()
|
||||
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
|
||||
"_npc_entity_colors must be cleared after conversation ends (#573)"
|
||||
).is_true()
|
||||
assert_int(box._next_npc_color).override_failure_message(
|
||||
"_next_npc_color must reset to 0 after conversation ends (#573)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_color_registry_reset_gives_fresh_assignment() -> void:
|
||||
## After conversation end + new conversation, same entity ID gets a color
|
||||
## (may differ from previous conversation — that's fine, per-conversation).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [], 500)
|
||||
var color1: Color = box._npc_entity_colors.get(500, Color.BLACK)
|
||||
box.hide_dialogue()
|
||||
box.show_dialogue("NPC", "Hi again.", [], 500)
|
||||
var color2: Color = box._npc_entity_colors.get(500, Color.BLACK)
|
||||
# Both should be valid palette colors (not BLACK fallback)
|
||||
assert_that(color1).override_failure_message(
|
||||
"First conversation color must be a palette color (#573)"
|
||||
).is_not_equal(Color.BLACK)
|
||||
assert_that(color2).override_failure_message(
|
||||
"Second conversation color must be a palette color (#573)"
|
||||
).is_not_equal(Color.BLACK)
|
||||
|
||||
|
||||
# -- show_dialogue entity ID threading ----------------------------------------
|
||||
|
||||
func test_show_dialogue_registers_npc_color() -> void:
|
||||
## show_dialogue with a valid entity ID must register the color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("Kael", "Welcome.", [], 600)
|
||||
assert_bool(box._npc_entity_colors.has(600)).override_failure_message(
|
||||
"show_dialogue must register entity color when npc_entity_id provided (#573)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_show_dialogue_without_entity_id_no_registration() -> void:
|
||||
## show_dialogue without entity ID should not register any color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
|
||||
"show_dialogue without entity ID must not register colors (#573)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- append_dialogue_response defensive guard ----------------------------------
|
||||
|
||||
func test_append_dialogue_response_registers_color_if_missing() -> void:
|
||||
## append_dialogue_response with a valid entity_id must register the color
|
||||
## even if show_dialogue was not called first (defensive guard).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.append_dialogue_response("Voss", "I see.", 700)
|
||||
assert_bool(box._npc_entity_colors.has(700)).override_failure_message(
|
||||
"append_dialogue_response must register color for unknown entity ID (#573)"
|
||||
).is_true()
|
||||
@@ -268,6 +268,79 @@ func test_game_state_visible_positions_cleared_on_new_snapshot() -> void:
|
||||
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||
|
||||
|
||||
# -- #585: BoundaryWall tiles — visible in fog, not persistently explored ------
|
||||
|
||||
func test_boundary_wall_tiles_not_in_visible_positions() -> void:
|
||||
## #585: BoundaryWall margin tiles must NOT enter visible_positions.
|
||||
## They are rendered via tile_renderer (from visible_tiles) but must not
|
||||
## update the player's fog exploration memory.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
|
||||
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_false()
|
||||
assert_that(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
||||
|
||||
|
||||
func test_boundary_wall_tiles_in_visibility_sectors() -> void:
|
||||
## BoundaryWall visibility sector is still tracked in visibility_sectors
|
||||
## (for potential future use — wall coloring, etc.)
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [
|
||||
{"x": 3, "y": 3, "z": 0, "visibility": "BoundaryWall"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.visibility_sectors.has(Vector2i(3, 3))).is_true()
|
||||
assert_that(GameState.visibility_sectors[Vector2i(3, 3)]).is_equal("BoundaryWall")
|
||||
|
||||
|
||||
func test_boundary_positions_cleared_on_new_snapshot() -> void:
|
||||
## BoundaryWall positions are cleared each snapshot so stale walls don't persist.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [{"x": 7, "y": 7, "z": 0, "visibility": "BoundaryWall"}],
|
||||
})
|
||||
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_true()
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_false()
|
||||
assert_that(GameState.boundary_positions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_boundary_wall_fog_vis_forward() -> void:
|
||||
## #585: BoundaryWall tiles must lift fog (VIS_FORWARD = 255) so wall content composites.
|
||||
## visible_positions excludes boundary tiles; fog_state writes vis bytes for them separately.
|
||||
## Reads _vis_bytes directly (packed byte array) to avoid ImageTexture.get_image() lag.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [
|
||||
{"x": 0, "y": 0, "z": 0, "visibility": "Forward"}, # normal LOS tile
|
||||
{"x": 1, "y": 0, "z": 0, "visibility": "BoundaryWall"}, # margin tile
|
||||
],
|
||||
})
|
||||
fog_state.update_from_state()
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var normal_idx: int = (0 - oy) * w + (0 - ox)
|
||||
var boundary_idx: int = (0 - oy) * w + (1 - ox)
|
||||
assert_int(vis[normal_idx]).is_equal(FogState.VIS_FORWARD) # normal tile: VIS_FORWARD
|
||||
assert_int(vis[boundary_idx]).is_equal(FogState.VIS_FORWARD) # boundary also fog-lifted
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
|
||||
|
||||
# -- Z-layer compliance (D-049) -----------------------------------------------
|
||||
|
||||
func test_fog_overlay_z_layer() -> void:
|
||||
|
||||
@@ -24,12 +24,14 @@ func before_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
|
||||
|
||||
# -- Spec constants (D-059) ---------------------------------------------------
|
||||
@@ -489,6 +491,139 @@ func test_visible_positions_cleared_on_new_snapshot() -> void:
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||
|
||||
|
||||
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
|
||||
|
||||
func test_boundary_positions_populated_from_snapshot() -> void:
|
||||
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
|
||||
# Fog lifts for boundary wall tiles so wall content composites correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 20,
|
||||
"visible_tiles": [
|
||||
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
|
||||
"Forward tile must be in visible_positions"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||
"BoundaryWall tile must NOT be in visible_positions (#585)"
|
||||
).is_false()
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||
"BoundaryWall tile must be in boundary_positions (#585)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
|
||||
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
|
||||
# They render through fog but are not stored as exploration memory.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if vis_bytes == null or exp_bytes == null:
|
||||
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 6 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= vis_bytes.size():
|
||||
return
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
|
||||
).is_not_equal(fog_state.EXP_VISIBLE)
|
||||
|
||||
|
||||
func test_boundary_wall_stays_unexplored_after_leaving_los() -> void:
|
||||
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
|
||||
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
|
||||
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: BoundaryWall at (6,5) is visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: both leave LOS
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 6 - ox
|
||||
var py := 5 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
|
||||
).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
|
||||
|
||||
func test_boundary_wall_cleared_on_new_snapshot() -> void:
|
||||
# #585: boundary_positions must be cleared each tick — old walls must not persist.
|
||||
# BoundaryWall positions shift as the player moves; stale positions would lift fog
|
||||
# where no wall exists.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 30,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 31,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
|
||||
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Performance (D-059) -------------------------------------------------------
|
||||
|
||||
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
|
||||
|
||||
@@ -218,9 +218,9 @@ func test_entity_renderer_positions_centered() -> void:
|
||||
renderer.update_entities([_test_entities[0]])
|
||||
|
||||
var node = renderer.entity_nodes[1]
|
||||
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
|
||||
var expected_x: float = 5.0 * Constants.TILE_SIZE + offset
|
||||
var expected_y: float = 5.0 * Constants.TILE_SIZE + offset
|
||||
# Sprite2D renderer: ENTITY_OFFSET_X=0.0, ENTITY_OFFSET_Y=TILE_SIZE-ENTITY_HEIGHT=0.0
|
||||
var expected_x: float = 5.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||
var expected_y: float = 5.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
assert_that(node.position.x).is_equal_approx(expected_x, 0.01)
|
||||
assert_that(node.position.y).is_equal_approx(expected_y, 0.01)
|
||||
renderer.queue_free()
|
||||
@@ -248,9 +248,9 @@ func test_entity_renderer_player_color_differs_from_npc() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
|
||||
var player_node = renderer.entity_nodes[1] as ColorRect
|
||||
var npc_node = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(player_node.color != npc_node.color).is_true()
|
||||
var player_node = renderer.entity_nodes[1] as Sprite2D
|
||||
var npc_node = renderer.entity_nodes[2] as Sprite2D
|
||||
assert_that(player_node.self_modulate != npc_node.self_modulate).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_empty_entities_clears_all() -> void:
|
||||
@@ -268,23 +268,23 @@ func test_entity_renderer_empty_entities_clears_all() -> void:
|
||||
func test_entity_renderer_player_uses_d033_color() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities_v2)
|
||||
var player_node = renderer.entity_nodes[1] as ColorRect
|
||||
assert_that(player_node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
var player_node = renderer.entity_nodes[1] as Sprite2D
|
||||
assert_that(player_node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_npc_uses_unknown_teal() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities_v2)
|
||||
var npc_node = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(npc_node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
var npc_node = renderer.entity_nodes[2] as Sprite2D
|
||||
assert_that(npc_node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_object_uses_grey() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(obj)
|
||||
var node = renderer.entity_nodes[3] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
var node = renderer.entity_nodes[3] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_peripheral_entity_dimmed() -> void:
|
||||
@@ -319,14 +319,14 @@ func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
|
||||
# {facing_angle → expected indicator rotation}
|
||||
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
|
||||
var angles := {
|
||||
-PI / 2.0: 0.0, # North
|
||||
-PI / 4.0: PI / 4.0, # Northeast
|
||||
0.0: PI / 2.0, # East
|
||||
PI / 4.0: 3.0 * PI / 4.0, # Southeast
|
||||
PI / 2.0: PI, # South
|
||||
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
|
||||
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
|
||||
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
|
||||
-PI / 2.0: 0.0, # North
|
||||
-PI / 4.0: PI / 4.0, # Northeast
|
||||
0.0: PI / 2.0, # East
|
||||
PI / 4.0: 3.0 * PI / 4.0, # Southeast
|
||||
PI / 2.0: PI, # South
|
||||
3.0 * PI / 4.0: 5.0 * PI / 4.0, # Southwest (raw: 3PI/4 + PI/2 = 5PI/4)
|
||||
PI: 3.0 * PI / 2.0, # West (raw: PI + PI/2 = 3PI/2)
|
||||
-3.0 * PI / 4.0: -PI / 4.0, # Northwest: wraps negative — Godot returns raw un-normalised rotation
|
||||
}
|
||||
renderer.update_entities(_test_entities_v2)
|
||||
var player_node = renderer.entity_nodes[1]
|
||||
@@ -405,9 +405,9 @@ func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
|
||||
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
var node = renderer.entity_nodes[5]
|
||||
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
|
||||
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
|
||||
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
|
||||
# Sprite2D renderer: ENTITY_OFFSET_X=0.0, ENTITY_OFFSET_Y=TILE_SIZE-ENTITY_HEIGHT=0.0
|
||||
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X, 0.01)
|
||||
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
class_name DebugConsole
|
||||
extends Control
|
||||
|
||||
## In-game debug console (#581). Tilde key (`) toggles open/closed.
|
||||
## Semi-transparent panel anchored to bottom ~40% of screen.
|
||||
## Dispatches DebugCommandKind variants to server via SimBridge.
|
||||
## Settings-toggled; enabled state persisted in user://settings.cfg.
|
||||
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
|
||||
|
||||
signal pause_requested # D-088: pause sim while console is open
|
||||
signal unpause_requested # D-088: unpause sim when console closes
|
||||
|
||||
const PREFS_PATH := "user://settings.cfg"
|
||||
const PREFS_SECTION := "debug"
|
||||
const PREFS_KEY_ENABLED := "console_enabled"
|
||||
const MAX_LOG_LINES := 50
|
||||
|
||||
const BG_COLOR := Color(0.04, 0.04, 0.06, 0.92)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color("#c8d0e0")
|
||||
const SUCCESS_COLOR := Color("#6bc9a6")
|
||||
const ERROR_COLOR := Color("#d45d5d")
|
||||
const INPUT_COLOR := Color("#e8c547")
|
||||
|
||||
var _enabled: bool = true
|
||||
var _open: bool = false
|
||||
var _log_lines: Array[String] = []
|
||||
var _panel: PanelContainer = null
|
||||
var _output_log: RichTextLabel = null
|
||||
var _input_line: LineEdit = null
|
||||
var _history: Array[String] = []
|
||||
var _history_idx: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_prefs()
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
get_viewport().size_changed.connect(_update_panel_layout)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.anchor_left = 0.0
|
||||
_panel.anchor_top = 0.6
|
||||
_panel.anchor_right = 1.0
|
||||
_panel.anchor_bottom = 1.0
|
||||
_panel.offset_left = 0.0
|
||||
_panel.offset_top = 0.0
|
||||
_panel.offset_right = 0.0
|
||||
_panel.offset_bottom = 0.0
|
||||
|
||||
var bg_style := StyleBoxFlat.new()
|
||||
bg_style.bg_color = BG_COLOR
|
||||
bg_style.border_color = BORDER_COLOR
|
||||
bg_style.border_width_top = 1
|
||||
bg_style.content_margin_left = 8.0
|
||||
bg_style.content_margin_right = 8.0
|
||||
bg_style.content_margin_top = 6.0
|
||||
bg_style.content_margin_bottom = 6.0
|
||||
_panel.add_theme_stylebox_override("panel", bg_style)
|
||||
add_child(_panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
_panel.add_child(vbox)
|
||||
|
||||
_output_log = RichTextLabel.new()
|
||||
_output_log.bbcode_enabled = true
|
||||
_output_log.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_output_log.scroll_following = true
|
||||
_output_log.selection_enabled = true
|
||||
_output_log.add_theme_color_override("default_color", TEXT_COLOR)
|
||||
_output_log.add_theme_font_size_override("normal_font_size", 13)
|
||||
vbox.add_child(_output_log)
|
||||
|
||||
var sep := HSeparator.new()
|
||||
vbox.add_child(sep)
|
||||
|
||||
_input_line = LineEdit.new()
|
||||
_input_line.placeholder_text = "enter command (help for list)"
|
||||
_input_line.clear_button_enabled = false
|
||||
_input_line.add_theme_font_size_override("font_size", 13)
|
||||
_input_line.add_theme_color_override("font_color", INPUT_COLOR)
|
||||
_input_line.text_submitted.connect(_on_input_submitted)
|
||||
_input_line.gui_input.connect(_on_input_key)
|
||||
vbox.add_child(_input_line)
|
||||
|
||||
|
||||
func _update_panel_layout() -> void:
|
||||
# Anchors handle resize automatically; no manual size calc needed.
|
||||
pass
|
||||
|
||||
|
||||
# -- Input handling --
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
if not event is InputEventKey or not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_QUOTELEFT:
|
||||
get_viewport().set_input_as_handled()
|
||||
_toggle()
|
||||
return
|
||||
if _open:
|
||||
# Consume all keyboard events — prevent movement/action leaking through
|
||||
get_viewport().set_input_as_handled()
|
||||
if event.keycode == KEY_ESCAPE:
|
||||
_close()
|
||||
|
||||
|
||||
func _on_input_key(event: InputEvent) -> void:
|
||||
if not event is InputEventKey or not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_UP:
|
||||
_history_up()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.keycode == KEY_DOWN:
|
||||
_history_down()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _toggle() -> void:
|
||||
if _open:
|
||||
_close()
|
||||
else:
|
||||
_open_console()
|
||||
|
||||
|
||||
func _open_console() -> void:
|
||||
_open = true
|
||||
visible = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_input_line.clear()
|
||||
_input_line.grab_focus()
|
||||
_history_idx = -1
|
||||
pause_requested.emit() # D-088: pause sim while typing debug commands
|
||||
|
||||
|
||||
func _close() -> void:
|
||||
_open = false
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_input_line.release_focus()
|
||||
unpause_requested.emit() # D-088: resume sim when console closes
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _open
|
||||
|
||||
|
||||
# -- Command input --
|
||||
|
||||
func _on_input_submitted(text: String) -> void:
|
||||
var trimmed := text.strip_edges()
|
||||
_input_line.clear()
|
||||
_history_idx = -1
|
||||
if trimmed.is_empty():
|
||||
return
|
||||
if _history.is_empty() or _history[0] != trimmed:
|
||||
_history.push_front(trimmed)
|
||||
if _history.size() > 20:
|
||||
_history.pop_back()
|
||||
_append_text("> " + trimmed, TEXT_COLOR)
|
||||
_dispatch(trimmed)
|
||||
|
||||
|
||||
func _dispatch(line: String) -> void:
|
||||
var parts := line.split(" ", false)
|
||||
if parts.is_empty():
|
||||
return
|
||||
var cmd := parts[0].to_lower()
|
||||
match cmd:
|
||||
"help":
|
||||
_print_help()
|
||||
"ticks":
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
_append_text("usage: ticks <n>", ERROR_COLOR)
|
||||
return
|
||||
var n := int(parts[1])
|
||||
if n <= 0:
|
||||
_append_text("ticks: n must be > 0", ERROR_COLOR)
|
||||
return
|
||||
_send_debug({"AdvanceTicks": n})
|
||||
"contaminate":
|
||||
_send_debug("SkipToContamination")
|
||||
"tp":
|
||||
if parts.size() < 2:
|
||||
_append_text("usage: tp <x> <y> [z] or tp <location_name>", ERROR_COLOR)
|
||||
return
|
||||
if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int():
|
||||
var z := 0
|
||||
if parts.size() >= 4:
|
||||
if parts[3].is_valid_int():
|
||||
z = int(parts[3])
|
||||
else:
|
||||
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
|
||||
_send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}})
|
||||
else:
|
||||
var loc := " ".join(PackedStringArray(parts.slice(1)))
|
||||
_send_debug({"TeleportToLocation": loc})
|
||||
"activate":
|
||||
_send_debug("ForceContaminationActivate")
|
||||
"triangle":
|
||||
if parts.size() < 2:
|
||||
_append_text("usage: triangle <id>", ERROR_COLOR)
|
||||
return
|
||||
_send_debug({"ForceTriangleActivation": parts[1]})
|
||||
"npc":
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
_append_text("usage: npc <entity_id>", ERROR_COLOR)
|
||||
return
|
||||
_send_debug({"InspectNpc": int(parts[1])})
|
||||
"triangles":
|
||||
_send_debug("ListTriangles")
|
||||
"pop":
|
||||
_send_debug("ListPopulation")
|
||||
"status":
|
||||
_send_debug("GetContaminationStatus")
|
||||
_:
|
||||
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.DEBUG_COMMAND,
|
||||
"action_data": kind,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
if err != OK:
|
||||
_append_text("send error: %s" % error_string(err), ERROR_COLOR)
|
||||
|
||||
|
||||
# -- Response display --
|
||||
|
||||
## Append a server debug response to the output log. Auto-opens console if closed
|
||||
## (only if console is enabled — respect user's settings toggle).
|
||||
func append_response(response: Dictionary) -> void:
|
||||
var success: bool = response.get("success", false)
|
||||
var text: String = response.get("text", "")
|
||||
var color := SUCCESS_COLOR if success else ERROR_COLOR
|
||||
_append_text(text, color)
|
||||
if not _open and _enabled:
|
||||
_open_console()
|
||||
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
func _append_text(text: String, color: Color) -> void:
|
||||
var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped])
|
||||
if _log_lines.size() > MAX_LOG_LINES:
|
||||
_log_lines = _log_lines.slice(_log_lines.size() - MAX_LOG_LINES)
|
||||
if _output_log:
|
||||
_output_log.text = "\n".join(_log_lines)
|
||||
|
||||
|
||||
func _print_help() -> void:
|
||||
_append_text(
|
||||
"Commands:\n"
|
||||
+ " ticks <n> — fast-forward N ticks\n"
|
||||
+ " contaminate — skip to contamination phase\n"
|
||||
+ " tp <x> <y> [z] — teleport to tile position\n"
|
||||
+ " tp <location> — teleport to named location\n"
|
||||
+ " activate — force contamination activate\n"
|
||||
+ " triangle <id> — force triangle activation\n"
|
||||
+ " npc <entity_id> — inspect NPC state\n"
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ " help — this list",
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# -- Command history --
|
||||
|
||||
func _history_up() -> void:
|
||||
if _history.is_empty():
|
||||
return
|
||||
_history_idx = mini(_history_idx + 1, _history.size() - 1)
|
||||
_input_line.text = _history[_history_idx]
|
||||
_input_line.caret_column = _input_line.text.length()
|
||||
|
||||
|
||||
func _history_down() -> void:
|
||||
if _history_idx <= 0:
|
||||
_history_idx = -1
|
||||
_input_line.clear()
|
||||
return
|
||||
_history_idx -= 1
|
||||
_input_line.text = _history[_history_idx]
|
||||
_input_line.caret_column = _input_line.text.length()
|
||||
|
||||
|
||||
# -- Settings --
|
||||
|
||||
func set_enabled(enabled: bool) -> void:
|
||||
_enabled = enabled
|
||||
if not _enabled and _open:
|
||||
_close()
|
||||
_save_prefs()
|
||||
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return _enabled
|
||||
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
return
|
||||
_enabled = cfg.get_value(PREFS_SECTION, PREFS_KEY_ENABLED, true)
|
||||
|
||||
|
||||
func _save_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.load(PREFS_PATH) # load existing (may have other sections like "audio")
|
||||
cfg.set_value(PREFS_SECTION, PREFS_KEY_ENABLED, _enabled)
|
||||
var err := cfg.save(PREFS_PATH)
|
||||
if err != OK:
|
||||
push_warning("DebugConsole: failed to save prefs (%d)" % err)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8pvt3xr7kmd2
|
||||
@@ -0,0 +1,15 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://b2ndm9rvx8cqp"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c8pvt3xr7kmd2" path="res://ui/debug_console.gd" id="1_debug_console"]
|
||||
|
||||
; #581: In-game debug console. Tilde key toggles. ModalLayer.
|
||||
; UI built programmatically in _ready() — scene contains only root node + script.
|
||||
[node name="DebugConsole" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_debug_console")
|
||||
+68
-13
@@ -45,6 +45,15 @@ var _option_texts: Array[String] = []
|
||||
var _option_is_confrontation: Array[bool] = []
|
||||
var _npc_name: String = ""
|
||||
|
||||
# -- Entity color registry (#573) --
|
||||
# Maps entity_id → Color for dialogue participants.
|
||||
# Assigned from _npc_colors palette on first encounter; player uses _player_color.
|
||||
# v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid
|
||||
# palette exhaustion (8 entries) across long sessions with 9+ NPCs.
|
||||
var _npc_entity_colors: Dictionary = {} # entity_id -> Color
|
||||
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
|
||||
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
|
||||
|
||||
# -- UI state --
|
||||
var _active_tween: Tween = null
|
||||
var _beat_tween: Tween = null # D-063: confrontation beat delay
|
||||
@@ -186,16 +195,23 @@ func _update_layout() -> void:
|
||||
## speaker/target: display names. text: the spoken line.
|
||||
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
|
||||
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
|
||||
func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void:
|
||||
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
|
||||
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
|
||||
func append_line(speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
|
||||
var pinned := not is_passive and _in_player_conversation
|
||||
_log_entries.append({
|
||||
var entry: Dictionary = {
|
||||
"speaker": speaker,
|
||||
"target": target,
|
||||
"text": text,
|
||||
"is_passive": is_passive,
|
||||
"pinned": pinned,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
}
|
||||
if speaker_entity_id >= 0:
|
||||
entry["speaker_entity_id"] = speaker_entity_id
|
||||
if target_entity_id >= 0:
|
||||
entry["target_entity_id"] = target_entity_id
|
||||
_log_entries.append(entry)
|
||||
_log_dirty = true
|
||||
_ensure_visible()
|
||||
|
||||
@@ -263,26 +279,34 @@ func on_conversation_ended(_event: Dictionary) -> void:
|
||||
|
||||
## Append the player's chosen response to the log.
|
||||
func append_player_line(target_npc: String, text: String) -> void:
|
||||
append_line(PLAYER_NAME, target_npc, text, false)
|
||||
append_line(PLAYER_NAME, target_npc, text, false, -1, _npc_entity_id)
|
||||
|
||||
|
||||
## Append an NPC follow-up line (from dialogue_response).
|
||||
func append_dialogue_response(npc_name: String, text: String) -> void:
|
||||
append_line(npc_name, PLAYER_NAME, text, false)
|
||||
## Note: expects show_dialogue() to have been called first to set _npc_entity_id.
|
||||
## Defensive: if entity_id is valid but not yet registered, _assign_npc_color handles it.
|
||||
func append_dialogue_response(npc_name: String, text: String, entity_id: int = -1) -> void:
|
||||
if entity_id >= 0:
|
||||
_assign_npc_color(entity_id)
|
||||
append_line(npc_name, PLAYER_NAME, text, false, entity_id, -1)
|
||||
|
||||
|
||||
# -- Active player conversation --
|
||||
|
||||
## Show dialogue with NPC speech and response options.
|
||||
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
|
||||
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
|
||||
## npc_entity_id: entity ID of the NPC for stable color assignment (#573).
|
||||
func show_dialogue(npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1) -> void:
|
||||
_npc_name = npc_name
|
||||
_npc_entity_id = npc_entity_id
|
||||
_cancel_beat()
|
||||
_in_player_conversation = true
|
||||
if npc_entity_id >= 0:
|
||||
_assign_npc_color(npc_entity_id)
|
||||
|
||||
# Append NPC's line to the log
|
||||
if not speech.is_empty():
|
||||
append_line(npc_name, PLAYER_NAME, speech, false)
|
||||
append_line(npc_name, PLAYER_NAME, speech, false, npc_entity_id, -1)
|
||||
|
||||
# Clear old options and show new ones
|
||||
_clear_options()
|
||||
@@ -316,6 +340,11 @@ func _end_player_conversation() -> void:
|
||||
entry.timestamp_msec = now
|
||||
_log_dirty = true
|
||||
|
||||
# #573: Clear per-conversation color registry to avoid palette exhaustion
|
||||
_npc_entity_colors.clear()
|
||||
_npc_entity_id = -1
|
||||
_next_npc_color = 0
|
||||
|
||||
# D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager
|
||||
audio_dip_cleared.emit()
|
||||
|
||||
@@ -509,17 +538,26 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
# Legacy string-keyed entry (player dialogue, backward compat)
|
||||
speaker = _escape_bbcode(entry.get("speaker", "?"))
|
||||
target = _escape_bbcode(entry.get("target", "?"))
|
||||
speaker_color = _color_for_name(entry.get("speaker", "?"))
|
||||
target_color = _color_for_name(entry.get("target", "?"))
|
||||
# #573: use entity-ID-bound color if available; fall back to name-hash
|
||||
var sp_eid: int = entry.get("speaker_entity_id", -1)
|
||||
var tg_eid: int = entry.get("target_entity_id", -1)
|
||||
if sp_eid >= 0 and _npc_entity_colors.has(sp_eid):
|
||||
speaker_color = _npc_entity_colors[sp_eid]
|
||||
else:
|
||||
speaker_color = _color_for_name(entry.get("speaker", "?"))
|
||||
if tg_eid >= 0 and _npc_entity_colors.has(tg_eid):
|
||||
target_color = _npc_entity_colors[tg_eid]
|
||||
else:
|
||||
target_color = _color_for_name(entry.get("target", "?"))
|
||||
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
|
||||
|
||||
var text: String = _escape_bbcode(entry.text)
|
||||
var is_passive: bool = entry.is_passive
|
||||
|
||||
# Desaturate passive name colours (Araminta review)
|
||||
# Desaturate passive name colours (Araminta review), re-enforce contrast floor after
|
||||
if is_passive:
|
||||
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
|
||||
target_color = _desaturate(target_color, PASSIVE_DESATURATION)
|
||||
speaker_color = _enforce_contrast(_desaturate(speaker_color, PASSIVE_DESATURATION))
|
||||
target_color = _enforce_contrast(_desaturate(target_color, PASSIVE_DESATURATION))
|
||||
|
||||
var sc := _color_with_alpha(speaker_color, alpha)
|
||||
var ac := _color_with_alpha(_arrow_color, alpha)
|
||||
@@ -546,6 +584,23 @@ static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
|
||||
|
||||
## Assign a palette color to an NPC entity ID on first encounter (#573).
|
||||
## Returns the same color on subsequent calls for the same entity ID.
|
||||
## TODO D-033 Phase 2: derive from relationship color — current independent palette
|
||||
## will need alignment when relationship-based entity colors arrive.
|
||||
func _assign_npc_color(entity_id: int) -> Color:
|
||||
if entity_id < 0:
|
||||
return _speech_color
|
||||
if _npc_entity_colors.has(entity_id):
|
||||
return _npc_entity_colors[entity_id]
|
||||
if _npc_colors.is_empty():
|
||||
return _speech_color
|
||||
var color := _enforce_contrast(_npc_colors[_next_npc_color % _npc_colors.size()])
|
||||
_next_npc_color += 1
|
||||
_npc_entity_colors[entity_id] = color
|
||||
return color
|
||||
|
||||
|
||||
## Get a stable color for a character name, with contrast floor enforcement.
|
||||
func _color_for_name(char_name: String) -> Color:
|
||||
if char_name == PLAYER_NAME:
|
||||
|
||||
@@ -11,7 +11,7 @@ const TITLE_COLOR := Color("#4a9ebb")
|
||||
const FONT_SIZE := 14
|
||||
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_HEIGHT := 340
|
||||
const BOX_HEIGHT := 376 # +36 for Debug Console row
|
||||
const PADDING := 20
|
||||
const ROW_HEIGHT := 36
|
||||
|
||||
@@ -28,6 +28,7 @@ var _active: bool = false
|
||||
var _container: VBoxContainer = null
|
||||
|
||||
signal closed
|
||||
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -109,6 +110,34 @@ func _build_ui() -> void:
|
||||
db_label.text = _format_db(value)
|
||||
)
|
||||
|
||||
# #581: Debug Console toggle
|
||||
var debug_hbox := HBoxContainer.new()
|
||||
debug_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
|
||||
_container.add_child(debug_hbox)
|
||||
|
||||
var debug_label := Label.new()
|
||||
debug_label.text = "Debug Console"
|
||||
debug_label.custom_minimum_size = Vector2(150, 0)
|
||||
debug_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
debug_label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
debug_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
debug_hbox.add_child(debug_label)
|
||||
|
||||
var debug_check := CheckButton.new()
|
||||
# Query live DebugConsole node if available; fall back to prefs file
|
||||
var console_node := get_node_or_null("/root/Main/ModalLayer/DebugConsole")
|
||||
if console_node and console_node.has_method("is_enabled"):
|
||||
debug_check.button_pressed = console_node.is_enabled()
|
||||
else:
|
||||
var cfg := ConfigFile.new()
|
||||
debug_check.button_pressed = true
|
||||
if cfg.load(DebugConsole.PREFS_PATH) == OK:
|
||||
debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true)
|
||||
debug_check.toggled.connect(func(enabled: bool) -> void:
|
||||
debug_console_toggled.emit(enabled)
|
||||
)
|
||||
debug_hbox.add_child(debug_check)
|
||||
|
||||
# Spacer
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 8)
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
display_name: "Sova Transit District"
|
||||
description: >
|
||||
A 40-year-old prefab-modular-retrofitted freight logistics hub on Station Sova.
|
||||
Three social sites: The Terminal (logistics hub), The Last Shift (bar),
|
||||
and maintenance corridors.
|
||||
Five locations: The Terminal (logistics hub), The Last Shift (bar),
|
||||
maintenance corridors, gate corridor (ground level), and the observation
|
||||
gallery (Commission-only, z=2 above the gate concourse).
|
||||
locations:
|
||||
- "the-terminal"
|
||||
- "the-last-shift"
|
||||
- "maintenance-corridors"
|
||||
- "gate-ground"
|
||||
- "gate-gallery"
|
||||
npc_count: 17
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Location: Gate Corridor — Observation Gallery
|
||||
# Source: D-093 gate cluster zone spec
|
||||
# Zone palette: surface #b8bec4 / fog tint #0a1222
|
||||
# z=2 (above gate concourse)
|
||||
|
||||
canonical_id: "krenn.sova.transit.location.gate-gallery"
|
||||
display_name: "Observation Gallery"
|
||||
description: >
|
||||
Commission-only observation gallery overlooking the gate concourse from
|
||||
z=2. The gallery rail is a transparent low wall — occupants can see
|
||||
down to the concourse below, but upward LOS from z=1 is blocked except
|
||||
at the staircase. Access restricted to Commission personnel.
|
||||
|
||||
tile_bounds:
|
||||
x_min: 0
|
||||
y_min: 0
|
||||
x_max: 31
|
||||
y_max: 9
|
||||
z: 2
|
||||
|
||||
# Legend:
|
||||
# R = Restricted (Commission-only access)
|
||||
# W = Wall (solid)
|
||||
#
|
||||
# Layout (32 wide x 10 tall):
|
||||
# Row 0: North wall with staircase entrance (col 1 = R, access point)
|
||||
# Rows 1-8: Gallery floor (R tiles — restricted access)
|
||||
# Row 9: South wall (gallery rail — transparent low wall, modeled as W;
|
||||
# cross-z LOS is handled server-side)
|
||||
|
||||
tiles:
|
||||
- "WRWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRW"
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
|
||||
sightlines:
|
||||
open: true
|
||||
notes: >
|
||||
Gallery rail is transparent — full downward LOS to gate concourse (z=1).
|
||||
Upward LOS from concourse to gallery is blocked except at staircase.
|
||||
Cross-z LOS behavior handled by server shadowcasting system.
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Location: Gate Corridor — Ground Level
|
||||
# Source: D-093 gate cluster zone spec
|
||||
# Zone palette: surface #b8bec4 / fog tint #0a1222
|
||||
|
||||
canonical_id: "krenn.sova.transit.location.gate-ground"
|
||||
display_name: "Gate Corridor"
|
||||
description: >
|
||||
The gate cluster handles all traffic between Station Sova and the horizon
|
||||
gate network. From north to south: the restricted aperture chamber where
|
||||
span gate transits occur, freight staging and passenger arrival halls,
|
||||
customs lanes (freight and pedestrian), and the wide gate concourse
|
||||
where arrivals disperse into the transit district.
|
||||
|
||||
tile_bounds:
|
||||
x_min: 0
|
||||
y_min: 0
|
||||
x_max: 39
|
||||
y_max: 33
|
||||
z: 1
|
||||
|
||||
# Legend:
|
||||
# F = Floor (walkable) W = Wall (solid, blocks LOS)
|
||||
# R = Restricted (access-tier gated)
|
||||
#
|
||||
# Layout (40 wide x 34 tall):
|
||||
# Row 0: North wall
|
||||
# Rows 1-4: Aperture chamber (centered 8 wide, R tiles — restricted)
|
||||
# Row 5: Wall separator
|
||||
# Rows 6-13: Freight staging (west, 24 tiles) | passenger arrival (east, 13 tiles)
|
||||
# Row 14: Wall with door gaps (cols 12, 32)
|
||||
# Rows 15-24: Freight customs (west, 20 tiles, 3 lanes at 6vt + wall stubs)
|
||||
# | corridor (6 tiles) | ped customs (east, 10 tiles, 3 lanes)
|
||||
# Row 25: Wall with wide opening to concourse (cols 5-34 open)
|
||||
# Rows 26-33: Gate concourse (full 38-tile width, 8 rows — public open space)
|
||||
#
|
||||
# D-093 zone dimensions: freight customs 20x10, ped customs 12x10 (10 rows each)
|
||||
# Corridor width: gate concourse 8vt (D-093)
|
||||
|
||||
tiles:
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
|
||||
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
|
||||
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
|
||||
- "WWWWWWWWWWWWWWWWRRRRRRRRWWWWWWWWWWWWWWWW"
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFFFW"
|
||||
- "WWWWWWWWWWWWFWWWWWWWWWWWWWWWWWWWFWWWWWWW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WFFFFFFWFFFFFFWFFFFFFWFFFFFFWFFWFFFWFFFW"
|
||||
- "WWWWWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWWWWW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
|
||||
sightlines:
|
||||
open: false
|
||||
notes: >
|
||||
Gate concourse (rows 26-33) is wide open with full sightlines.
|
||||
Customs lanes have wall stubs creating partial occlusion between
|
||||
lanes. Freight customs (west, 20 tiles, 3 lanes at 6vt) is wider
|
||||
than pedestrian customs (east, 10 tiles, 3 lanes). Aperture chamber
|
||||
is fully walled and restricted. Cross-z LOS from the observation
|
||||
gallery (z=2) into the concourse is handled server-side.
|
||||
+53
-1
@@ -1,2 +1,54 @@
|
||||
# Location: Maintenance Corridors (smuggling spaces)
|
||||
# canonical_id: krenn.sova.transit.location.maintenance-corridors
|
||||
# Source: D-093, #313 (Sprint 12 spatial layout)
|
||||
# Zone palette: surface #4e5054 / fog tint #101214
|
||||
# z=0 (Era 1, below main structures)
|
||||
|
||||
canonical_id: "krenn.sova.transit.location.maintenance-corridors"
|
||||
display_name: "Maintenance Corridors"
|
||||
description: >
|
||||
Era 1 maintenance infrastructure beneath the transit district. A long
|
||||
transition corridor connects the logistics hub to the bar district.
|
||||
Restricted storage at the west end is shared with The Terminal above.
|
||||
A maintenance hatch at the east end provides the cross-z connection
|
||||
to the main level. Everything reads as mundane maintenance — the ring
|
||||
operates here because nobody looks twice.
|
||||
|
||||
tile_bounds:
|
||||
x_min: 0
|
||||
y_min: 0
|
||||
x_max: 57
|
||||
y_max: 5
|
||||
z: 0
|
||||
|
||||
# Legend:
|
||||
# F = Floor (walkable) W = Wall (solid, blocks LOS)
|
||||
# R = Restricted (access-tier gated)
|
||||
#
|
||||
# Layout (58 wide x 6 tall):
|
||||
# Row 0: North wall
|
||||
# Row 1: Restricted storage (west, 8 tiles) | wall | corridor (40 tiles) | wall | hatch room (east, 6)
|
||||
# Row 2: Doors connect rooms (F in wall positions at cols 9 and 50)
|
||||
# Row 3-4: Same as row 1 (walls between rooms)
|
||||
# Row 5: South wall
|
||||
#
|
||||
# Corridor width: 2vt visible at rows 1-4 (internal height, maintenance standard)
|
||||
# Transition corridor: 40 tiles (~40m at 1m/vt) between storage and hatch per D-093
|
||||
# Restricted storage: R tiles for access restriction
|
||||
# Maintenance hatch: F tiles (cross-z connection is future movement feature)
|
||||
# Design: mundane maintenance appearance per D-093 G-08
|
||||
|
||||
tiles:
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WRRRRRRRRFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WRRRRRRRRWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
|
||||
sightlines:
|
||||
open: false
|
||||
notes: >
|
||||
Long straight corridor (40 tiles) with limited concealment. Storage and
|
||||
hatch rooms are separated by walls with single-tile doors at row 2.
|
||||
Mundane appearance per D-093 G-08 — no visually suspicious tile
|
||||
arrangements.
|
||||
|
||||
+70
-1
@@ -1,2 +1,71 @@
|
||||
# Location: The Last Shift (bar)
|
||||
# canonical_id: krenn.sova.transit.location.the-last-shift
|
||||
# Source: D-093, #312 (Sprint 12 spatial layout)
|
||||
# Zone palette: surface #6b4018 / fog tint #200c04
|
||||
|
||||
canonical_id: "krenn.sova.transit.location.the-last-shift"
|
||||
display_name: "The Last Shift"
|
||||
description: >
|
||||
A converted maintenance bay turned bar. The long counter runs along the
|
||||
west wall with full sightlines across the room. A corner booth in the
|
||||
northeast sees the bar, card table, entrance, and back room — prime
|
||||
observation real estate. Scattered tables fill the main floor. A back
|
||||
room with an alley exit provides the key traversal route for the ring.
|
||||
|
||||
tile_bounds:
|
||||
x_min: 0
|
||||
y_min: 0
|
||||
x_max: 33
|
||||
y_max: 21
|
||||
z: 1
|
||||
|
||||
# Legend:
|
||||
# F = Floor (walkable) W = Wall (solid, blocks LOS)
|
||||
#
|
||||
# Layout (34 wide x 22 tall):
|
||||
# Row 0: North exterior wall
|
||||
# Rows 1-4: Main floor + corner booth (NE, cols 26-31, walled alcove)
|
||||
# Row 5: Bar counter starts (cols 1-2 = W fixture)
|
||||
# Rows 6-10: Bar counter (cols 1-2 W), tables (W stubs), card table (col 25)
|
||||
# Row 11: Open transition floor
|
||||
# Rows 12-14: Table clusters (W stubs at cols 8, 16, 24)
|
||||
# Row 15: News ticker mount (col 31 = W stub)
|
||||
# Row 16: Open floor
|
||||
# Row 17: Back room north wall with door (col 22 = F)
|
||||
# Rows 18-20: Main floor (west) + back room (east, cols 22-32)
|
||||
# Row 21: South wall with alley exit gap (cols 29-32 = F)
|
||||
|
||||
tiles:
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFWFFFFFFW"
|
||||
- "WWWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WWWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFW"
|
||||
- "WWWFFFFFFFWFFFFFFFWFFFFFFWFFFFFFFW"
|
||||
- "WWWFFFFFFFWFFFFFFFWFFFFFFFFFFFFFFW"
|
||||
- "WWWFFFFFFFFFFFWFFFFFFFFFFFFFFFFFFW"
|
||||
- "WWWFFFFFFFFFFFWFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFWFFFFFFFWFFFFFFFWFFFFFFFFW"
|
||||
- "WFFFFFFFWFFFFFFFWFFFFFFFWFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFWFWFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFFFW"
|
||||
- "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWFFFFW"
|
||||
|
||||
sightlines:
|
||||
open: false
|
||||
notes: >
|
||||
Corner booth (NE alcove, rows 1-4) has LOS to bar counter, card table
|
||||
area, main entrance, and back room door. Bar counter along west wall
|
||||
(cols 1-2, rows 5-10) gives staff full sightlines across the main
|
||||
floor. Back room is partially occluded by wall at col 21. Back room
|
||||
door at col 22. Table W-stubs create partial cover but do not fully
|
||||
block sightlines.
|
||||
|
||||
social_site: "bar"
|
||||
|
||||
+76
-1
@@ -1,2 +1,77 @@
|
||||
# Location: The Terminal (logistics hub)
|
||||
# canonical_id: krenn.sova.transit.location.the-terminal
|
||||
# Source: D-093, #311 (Sprint 12 spatial layout)
|
||||
# Zone palette: surface #7a8490 / fog tint #0d1520
|
||||
|
||||
canonical_id: "krenn.sova.transit.location.the-terminal"
|
||||
display_name: "The Terminal"
|
||||
description: >
|
||||
Sova's freight logistics hub. Scanner bays filter incoming cargo at the
|
||||
south entrance; a wide main corridor runs north through the manifest
|
||||
processing floor. The break room sits in the northeast. The supervisor's
|
||||
office overlooks the corridor through a large interior window. Restricted
|
||||
storage in the northwest corner is where the ring hides re-tagged cargo.
|
||||
|
||||
tile_bounds:
|
||||
x_min: 0
|
||||
y_min: 0
|
||||
x_max: 43
|
||||
y_max: 27
|
||||
z: 1
|
||||
|
||||
# Legend:
|
||||
# F = Floor (walkable) W = Wall (solid, blocks LOS)
|
||||
# V = Void (outside building) R = Restricted (access-tier gated)
|
||||
#
|
||||
# Layout (44 wide x 28 tall):
|
||||
# Rows 0: North exterior wall
|
||||
# Rows 1-4: Restricted storage (NW, 11 tiles) + corridor + supervisor office (NE, 9 tiles)
|
||||
# Row 5: Storage south wall; corridor + office continue
|
||||
# Rows 6-8: Open corridor; supervisor office east side
|
||||
# Row 7: ** Supervisor window — col 28 is F (LOS gap into office) **
|
||||
# Row 9: Supervisor office south wall
|
||||
# Rows 10-11: Main corridor (full width chokepoint — high sightline value)
|
||||
# Rows 12-17: Manifest processing (west, 14 tiles) + break room (east, 13 tiles)
|
||||
# Row 18: Break room south wall; manifest open to corridor
|
||||
# Rows 19-21: South corridor
|
||||
# Row 22: Scanner bay north walls with entry gaps
|
||||
# Rows 23-26: Scanner bay interiors (3 bays)
|
||||
# Row 27: South exterior wall
|
||||
|
||||
tiles:
|
||||
- "VVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWVVVVV"
|
||||
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWRRRRRRRRRRRWFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWWWWWWWWWWWWWFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFWWWWWWWWWWWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFWFFFWFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFWWWWWWWWWWWWWWWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWWWWFFFFFFWWWWFFFFFFFFWWWWFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWFFFFFFFFFWFFFFFFFFFFFWFFFFFFFFFFWVVVVV"
|
||||
- "VVVVVWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWVVVVV"
|
||||
|
||||
sightlines:
|
||||
open: false
|
||||
notes: >
|
||||
Supervisor office window faces main corridor at row 7, col 28 (F gap in
|
||||
wall line). Player standing in the corridor has direct LOS into the
|
||||
office interior. This is the primary investigative discovery moment.
|
||||
|
||||
social_site: "logistics-hub"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,116 @@
|
||||
# Sprint 23: Terrain — Client Tasks
|
||||
|
||||
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
|
||||
|
||||
**Branch:** `client`
|
||||
**Agents:** Stig (UI/rendering), Hoshe (QA)
|
||||
|
||||
---
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #573 | Dialogue speaker colors are position-based instead of speaker-bound | — |
|
||||
| #574 | Pre-existing test failures in test_entity_renderer (test_rendering.gd) | — |
|
||||
| #581 | Debug console client: tilde console UI, settings toggle, command dispatch | #580 (server) |
|
||||
| #585 | Walls at LOS boundary: client — render margin tiles correctly through fog | #584 (server) |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full descriptions.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-020 (ObserverSnapshot is the only data crossing IPC boundary), D-088 (3-state pause — Normal/Overlay/Paused)
|
||||
- `decisions/content.md` — D-033 (entity color = relationship to player; player color set at character creation), D-076 (dialogue box max-width 1200px)
|
||||
- `decisions/perception.md` — D-059 (fog system — 3-state: visible, explored, never-seen)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### #573 — Dialogue speaker colors
|
||||
|
||||
**What exists:** `client/ui/dialogue_box.gd` renders the dialogue box (D-028, D-076). Speaker colors are currently assigned by position (left speaker = color A, right speaker = color B), which means all left-side speakers share one color and all right-side speakers share another regardless of identity.
|
||||
|
||||
**What to deliver:** Bind speaker color to entity identity, not screen position. The `ObserverSnapshot` carries entity IDs on dialogue lines (check `client/scripts/protocol/protocol.gd` `_decode_nearby_interaction` for the entity_id field on dialogue events). Maintain a `Dict[entity_id -> Color]` in `dialogue_box.gd`. On first encounter with an entity ID, assign a color from a palette and store it. Subsequent lines from the same entity reuse the stored color. The palette should be distinct from D-033 relationship colors (those are world-layer entity colors; dialogue colors are UI-layer only).
|
||||
|
||||
**Non-obvious gotcha:** The player character is always one speaker. Assign the player a fixed color (e.g. the character's D-033 color, or a designated white/near-white). NPCs get assigned from the palette on first appearance in the conversation.
|
||||
|
||||
### #574 — Entity renderer test failures
|
||||
|
||||
**What exists:** `client/tests/test_rendering.gd` contains `test_entity_renderer_*` tests that fail on clean main (5aea6e2). Affected: positions_centered, player_color_differs_from_npc, player_uses_d033_color, npc_uses_unknown_teal, object_uses_grey, facing_indicator_rotation_accuracy, regression_345_entity_position_set_from_entity_id_entity.
|
||||
|
||||
**What to deliver:** Fix the failing tests. `client/scripts/rendering/entity_renderer.gd` likely changed without the tests being updated. Read the renderer to understand the current implementation, then update tests to match (or fix the renderer if it regressed from D-033 spec). Do not change the passing visual golden tests — only fix the unit tests in `test_rendering.gd`. Run `make test-client` to verify.
|
||||
|
||||
**Gotcha:** D-033 specifies entity color = relationship to player. If entity_renderer.gd was updated correctly but tests use stale expected values, update the test assertions. If entity_renderer.gd was updated incorrectly and broke D-033 compliance, fix the renderer.
|
||||
|
||||
### #581 — Debug console client
|
||||
|
||||
**What exists:** `client/ui/settings_dialog.gd` — existing settings panel with 5-bus audio volume sliders. `client/ui/debug_overlay.gd` — existing debug overlay tied to `OS.is_debug_build()` and a `debug_overlay` input action. `client/scripts/main.gd` — input routing. `client/scripts/protocol/protocol.gd` — `encode_player_input(tick, action_name, action_data)` and `decode_snapshot()`.
|
||||
|
||||
**What to deliver:**
|
||||
|
||||
1. `client/ui/debug_console.gd` + `debug_console.tscn` — semi-transparent panel (40% screen height, anchored bottom). Input line at bottom, scrollable output log above (50-line buffer). Tilde key (`KEY_QUOTELEFT`) toggles open/closed. When open, swallows all keyboard input except tilde (no movement commands leak).
|
||||
|
||||
2. Settings integration — add a "Debug Console" checkbox to `settings_dialog.gd`. Persisted in `user://settings.cfg` alongside audio volumes. Default: enabled. When disabled, tilde key is inert.
|
||||
|
||||
3. Command parsing and dispatch — parse input as `<command> [args...]`. Map to `DebugCommandKind` variants. Serialize via `Protocol.encode_player_input('debug_command', { kind: ..., args: ... })`. Commands: `ticks <n>`, `contaminate`, `tp <x> <y> [z]`, `tp <name>`, `activate`, `triangle <id>`, `npc <id>`, `triangles`, `pop`, `status`, `help`.
|
||||
|
||||
4. Response display — read `debug_response` from the decoded snapshot dict (new optional field from server #580). Format and append to output log. Complex payloads (NPC state, triangle list) displayed as formatted key-value blocks.
|
||||
|
||||
5. Protocol extension — update `protocol.gd` to encode `debug_command` action and decode `debug_response` from snapshot. Bump `Protocol.PROTOCOL_VERSION` to 18 to match server.
|
||||
|
||||
**Coordination required:** Stig must coordinate with Dudley on the PROTOCOL_VERSION bump (#580 server) before either PR is merged. Client and server PRs must land together or within the same merge window — a version mismatch will crash the connection.
|
||||
|
||||
**Blocked by:** #580 (server must define `DebugCommandKind` wire format before client serialization is finalized).
|
||||
|
||||
### #585 — LOS boundary wall rendering
|
||||
|
||||
**What exists:** `client/scripts/rendering/tile_renderer.gd` — `update_tiles()` processes `VisibleTile` entries from the snapshot. The tile accumulation fix (removed `clear()` from `update_tiles()`) means explored tiles stay rendered. `client/scripts/autoloads/fog_state.gd` — manages exploration state per tile.
|
||||
|
||||
**What to deliver:** Handle the new `VisibilitySector::BoundaryWall` tiles from server #584. In `update_tiles()`, detect boundary wall sector tiles and:
|
||||
- Render them to the TileMapLayer as wall tiles (so fog has content to composite over)
|
||||
- Do NOT record them as explored in `fog_state.gd` — they are "seen right now" only, not explored
|
||||
- Do NOT update the fog exploration texture for these tiles
|
||||
|
||||
The visual result: walls at the LOS edge are distinct from fog (they render as wall tiles) but disappear from the map when not in the current LOS cone (they don't leave a memory trace).
|
||||
|
||||
**Blocked by:** #584 (server must send the boundary tiles before client can render them).
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#573 (speaker color fix) — standalone
|
||||
#574 (renderer test fix) — standalone
|
||||
|
||||
#580 (server debug) → #581 (debug console)
|
||||
|
||||
#584 (server wall margin) → #585 (client wall margin)
|
||||
```
|
||||
|
||||
#573 and #574 are fully independent — start immediately. #581 is blocked on server work landing first. #585 is blocked on server #584 landing first.
|
||||
|
||||
---
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "fix(client): bind dialogue speaker colors to entity identity" \
|
||||
--description "body" --base main --head client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion (Client Criteria)
|
||||
|
||||
1. Dialogue conversations show consistent per-speaker colors regardless of screen position.
|
||||
2. All `test_entity_renderer_*` tests pass in `test_rendering.gd`.
|
||||
3. Tilde key opens debug console in-game; `status` command returns contamination state; `triangles` lists all active triangles.
|
||||
4. Debug console toggle visible in Settings menu; persists across restarts.
|
||||
5. Walls at LOS edge render as distinct tile content rather than blending into fog.
|
||||
6. `make test-client` green on client branch.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sprint 23: Terrain — Joint / Integration
|
||||
|
||||
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
|
||||
|
||||
**Agents:** All implementation agents (Dudley, Tyre, Hoshe, Stig, Araminta)
|
||||
|
||||
---
|
||||
|
||||
## Pre-Sprint Actions
|
||||
|
||||
These must happen at sprint start, before implementation tickets begin:
|
||||
|
||||
| Action | Owner | Blocks |
|
||||
|--------|-------|--------|
|
||||
| Confirm tile format (character codes, row encoding, multi-z approach) | Dudley + Araminta sync (day 1–2) | #582, #583 — Araminta cannot start until format is published in types.rs |
|
||||
| Confirm DebugCommandKind wire format (exact JSON/msgpack structure) | Dudley + Stig sync (day 1–2) | #581 — client dispatch serialization depends on this |
|
||||
| Confirm PROTOCOL_VERSION bump: 17 → 18 | Dudley + Stig | #580 server + #581 client must land together |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Team Dependencies
|
||||
|
||||
| Dependency | Direction | Notes |
|
||||
|------------|-----------|-------|
|
||||
| Tile format spec | Server (#577) → Visual (#582, #583) | Dudley publishes format in types.rs; Araminta starts authoring same day |
|
||||
| DebugCommandKind wire format | Server (#580) → Client (#581) | Stig cannot finalize protocol.gd encoding until Dudley publishes the enum |
|
||||
| PROTOCOL_VERSION 17→18 | Server (#580) ↔ Client (#581) | Both must land in the same merge window; version mismatch crashes the connection |
|
||||
| LOS wall margin | Server (#584) → Client (#585) | Client rendering change depends on server sending boundary tiles |
|
||||
|
||||
---
|
||||
|
||||
## Housekeeping Done This Sprint
|
||||
|
||||
The following stale tickets were closed during sprint planning:
|
||||
|
||||
| # | Reason |
|
||||
|---|--------|
|
||||
| #184 | Logistics hub template YAML was already complete in `templates/logistics-hub.yaml` |
|
||||
| #339 | Protocol version handshake already implemented in Sprint 19 via #555/#556 |
|
||||
| #143 | Superseded by richer #576 (tile-type layer with full spec) |
|
||||
| #144 | Superseded by #577 (location loader is the v0.1 equivalent) |
|
||||
| #145 | Superseded by richer #578 (chunk streaming with v0.1 full-preload mode) |
|
||||
| #154 | Functional cluster spatial specs live in D-025 and D-093 — already done |
|
||||
| #155 | Hand-crafted authoring is now #582/#583 with correct format and blocker |
|
||||
| #157 | Gate corridor is covered by #583 (Last Shift + maintenance + gate) |
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
The sprint is done when all of the following are observable:
|
||||
|
||||
1. **Walkable world boots.** Start the server in production mode (no gauntlet feature). It loads tile data from all four Sova locations (The Terminal, The Last Shift, maintenance corridors, gate corridor). No panic. WalkabilityMap populated from YAML — walls block movement, floors allow it.
|
||||
|
||||
2. **Storyteller activates from player behavior.** Run a test: spawn at The Terminal, observe Kael for 3000 ticks. EngagementRecord increments. After CONTAMINATION_DELAY_TICKS, activation_pass() fires and emits TriangleActivated for the hub-power or worried-partner triangle.
|
||||
|
||||
3. **Debug console works end-to-end.** Boot production game. Tilde key opens console. Type `status` — returns contamination state and current tick. Type `contaminate` — skips to contamination. Type `triangles` — lists all TriangleState entities with their IDs and classifications.
|
||||
|
||||
4. **Debug console in settings.** Open Settings menu. Debug Console toggle is visible. Disable it. Tilde key no longer opens console.
|
||||
|
||||
5. **Speaker colors are identity-bound.** Open a conversation with Kael, then end it. Open another conversation with Voss. Kael's color is consistent; Voss has a different color; neither color is position-based.
|
||||
|
||||
6. **LOS boundary walls render.** Walk up to a wall at the edge of vision. The wall tile is visible and distinct from fog rather than bleeding into the fog overlay.
|
||||
|
||||
7. **CI green.** `make ci` passes on all branches. No regressions.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan Alignment (D-030)
|
||||
|
||||
Sprint 23 is Phase 2 integration territory (D-030 Phase 2: integration tests + information boundary tests):
|
||||
|
||||
- **#576:** Unit tests — TileKind round-trip in WalkabilityMap. Phase 1 (data structure).
|
||||
- **#577:** Integration test — production boot loads tile data, WalkabilityMap is populated correctly. Phase 2.
|
||||
- **#578:** Unit test — chunk load/unload at player boundary. Phase 1.
|
||||
- **#570–#572–#579:** Integration test — 3000-tick simulation asserts EngagementRecord increments + TriangleActivated fires. Phase 2.
|
||||
- **#580/#581:** Manual acceptance — debug console smoke test (status, contaminate, triangles). Phase 2.
|
||||
- **#584/#585:** Visual regression test — add golden test for LOS boundary wall rendering. Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
| ID | Question | Blocks | Action |
|
||||
|----|----------|--------|--------|
|
||||
| Q-052 | Storyteller hint delivery channels | #579 downstream consumers | Read for context but does not block Sprint 23. #579 emits TriangleActivated; consumers wired in Sprint 24. |
|
||||
|
||||
---
|
||||
|
||||
## PR Merge Order
|
||||
|
||||
To avoid conflicts on shared files (`bridge/types.rs`, `protocol.gd`, `PROTOCOL_VERSION`):
|
||||
|
||||
1. Server PRs (#576, #570, #571, #572) — no client/visual dependencies; merge first.
|
||||
2. Server PR (#577 tile format) — merge and notify Araminta immediately so visual authoring starts.
|
||||
3. Server PRs (#578, #579, #584) — merge in any order after their respective blockers.
|
||||
4. **Server PR #580 (debug server) + Client PR #581 (debug client)** — must land together. Coordinate merge timing. Both bump PROTOCOL_VERSION to 18.
|
||||
5. Visual PRs (#582, #583) — merge after server #577 is in main.
|
||||
6. Client PRs (#573, #574) — no dependencies; merge anytime.
|
||||
7. Client PR #585 (LOS wall margin) — merge after server #584 is in main.
|
||||
|
||||
**Critical:** #580 and #581 share the PROTOCOL_VERSION bump. Do not merge one without the other.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Sprint 23: Terrain — Server Tasks
|
||||
|
||||
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
|
||||
|
||||
**Branch:** `server`
|
||||
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
|
||||
|
||||
---
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #576 | Chunk data structure: tile-type layer and ChunkStore resource | — |
|
||||
| #577 | Location YAML tile format and content loader integration | #576 |
|
||||
| #578 | Chunk loading/unloading: streaming around player position | #576 |
|
||||
| #570 | EngagementRecord component — track per-NPC engagement metrics | — |
|
||||
| #571 | MovementHistoryBuffer resource — player path history | — |
|
||||
| #572 | Storyteller lifecycle rules — multi-activation, cooldown, resolution | — |
|
||||
| #579 | Storyteller activation pass — implement activation_pass() system | #570, #571, #572 |
|
||||
| #580 | Debug console server: command handler and state manipulation API | — |
|
||||
| #584 | Walls at LOS boundary: server — extend visible_tiles with wall margin | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full descriptions.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-012 (tile/chunk spec), D-094 (chunk 32×32, block 128×128, district 256×256), D-110 (signed z-levels: base_z i8, z_levels u8)
|
||||
- `decisions/content.md` — D-023 (three-tier content model, storyteller activation), D-025 (functional cluster, 15-40 tile social sites), D-093 (Sova Transit District — full spatial spec with tile dimensions), D-029 (population entanglement ratio 30/50/20)
|
||||
- `decisions/architecture.md` — D-010 (determinism — BTreeMap, no float non-determinism), D-031 (10 ticks/game-minute, TICKS_PER_GAME_MINUTE)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### #576 — Chunk data structure: tile-type layer
|
||||
|
||||
**What exists:** `WalkabilityMap` in `server/src/simulation/movement.rs` stores per-tile walkability as `Vec<bool>` inside private `ChunkData` structs, indexed by `ChunkCoord { cx, cy, z }`. `CHUNK_SIZE = 32`. The public API is `is_walkable(pos)` / `set_walkable(pos, bool)`. Generator types (`GeneratorChunkData`, `TileId`, etc.) are stubs in `server/src/simulation/generator.rs`.
|
||||
|
||||
**What to deliver:** Extend `ChunkData` to carry a `TileKind` per tile. Define `TileKind` enum: `Floor | Wall | Void | Restricted`. Add `tile_kind(pos) -> TileKind` and `set_tile_kind(pos, kind)` to `WalkabilityMap`. Keep `is_walkable` / `set_walkable` API intact — many existing tests use it directly. All existing tests must pass.
|
||||
|
||||
**Non-obvious gotcha:** `ChunkData` is private. Only `WalkabilityMap` methods are the extension surface. Do not make `ChunkData` or `TileKind` pub unnecessarily — encapsulate through the map API.
|
||||
|
||||
### #577 — Location YAML tile format and loader
|
||||
|
||||
**What exists:** `Location` struct in `server/src/content/types.rs` has `tile_bounds: Option<TileBounds>` (bounding box only) and `sightlines`. The three location YAMLs (`the-terminal.yaml`, `the-last-shift.yaml`, `maintenance-corridors.yaml`) are 2-line stubs — header comments only. `load_district()` in `server/src/content/loader.rs` already calls `load_yaml_dir::<Location>()` on the locations directory; stubs are silently skipped.
|
||||
|
||||
**What to deliver:** Two things. First, agree the tile format with Araminta (visual team) on day 1–2 of the sprint — she cannot start authoring until you publish the format. Suggested format: `tiles: ["FFFFF", "FWWWF", "FFFFF"]` — array of strings, one char per tile, left-to-right = +x, top-to-bottom = +y. Characters: `F`=floor, `W`=wall, `V`=void, `R`=restricted. Post the agreed format as a comment block in `types.rs` before Araminta starts.
|
||||
|
||||
Second, implement `load_location_tiles(location: &Location, origin: TilePosition, map: &mut WalkabilityMap)` and call it from the production startup path in `server/src/main.rs` after NPCs/triangles are spawned. The Gauntlet startup path (`server/src/test_world/`) must be untouched.
|
||||
|
||||
**Dependency:** #576 must be merged first (needs `set_tile_kind` API). Unblocks #582 and #583 (Araminta's tile authoring).
|
||||
|
||||
### #578 — Chunk loading/unloading
|
||||
|
||||
**What exists:** `WalkabilityMap` is pre-allocated at startup via `WalkabilityMap::new(width, height, z_levels)` which eagerly creates all chunks. `ZLevelLoadState` enum is defined in `server/src/simulation/generator.rs` with `Loaded(GeneratorChunkData) | Skeleton(FloorZone) | Ungenerated` variants but not yet used at runtime.
|
||||
|
||||
**What to deliver:** A chunk streaming system. For v0.1 the entire hand-authored district fits in memory (256×256 visual = 8×8 chunks of 32 tiles). Pre-load all chunks at startup; the streaming system exists but covers the full map. Architecture must support future per-demand loading (v0.3+ generator). Add `ChunkLoadRadius` resource. Add a system that tracks player position → loaded chunk set → unloads distant chunks. Test: verify chunks at boundary are unloaded when player moves away.
|
||||
|
||||
**Blocked by:** #576.
|
||||
|
||||
### #570 — EngagementRecord component
|
||||
|
||||
**What exists:** Nothing. The KnowledgeGraph (`server/src/knowledge/`) tracks NPC information inventory but not engagement metrics. The storyteller activation spec in #162 requires three fields per observed entity: `observation_time_ticks`, `conversation_count`, `monologue_trigger_count`.
|
||||
|
||||
**What to deliver:** `EngagementRecord` as a bevy ECS `Component` on NPC entities. Three fields. The perception system (`server/src/perception/observation.rs`) should increment `observation_time_ticks` per tick the NPC is in the player's LOS. The dialogue system (`server/src/simulation/dialogue.rs`) should increment `conversation_count` on conversation start. The monologue system (`server/src/simulation/monologue.rs`) should increment `monologue_trigger_count` when a monologue fires for a specific NPC context. All three writes must be additive — no overwrite.
|
||||
|
||||
### #571 — MovementHistoryBuffer resource
|
||||
|
||||
**What exists:** Nothing. `TilePosition` tracks current position. No history buffer exists.
|
||||
|
||||
**What to deliver:** `MovementHistoryBuffer` as a bevy `Resource`. Ring buffer of the player's `TilePosition` over the last `ENGAGEMENT_WINDOW_TICKS = 3000` ticks. The movement system (`server/src/simulation/movement.rs`) appends to it each tick when the player moves. Expose a query method: `npcs_copresent_in_window(threshold: i32) -> Vec<Entity>` — returns NPC entities whose `TilePosition` was within `threshold` tiles of any player position in the buffer. Used by the activation pass (#579).
|
||||
|
||||
### #572 — Storyteller lifecycle rules
|
||||
|
||||
**What exists:** The contamination layer fires once and sets `ContaminationActive`. No activation lifecycle spec exists.
|
||||
|
||||
**What to deliver:** A decision/spec (can be a code comment block or a D-record filed with Qatux, not necessarily full implementation). For v0.1: single activation per session only. No concurrent activations. No cooldown needed (one and done). Triangle resolution: when a triangle reaches a terminal phase, mark it resolved; do not re-activate. Document these rules as constants and a comment in `server/src/storyteller/mod.rs`. These rules gate #579.
|
||||
|
||||
### #579 — Storyteller activation pass
|
||||
|
||||
**What exists:** `server/src/storyteller/mod.rs` has `ContaminationActive`, `ContaminationEventQueue`, and `tick_contamination_activation()`. No activation pass, no engagement scoring, no `TriangleActivated` event.
|
||||
|
||||
**What to deliver:** `activation_pass()` system implementing the 6 steps from #162. Gate → proximity query → engagement scoring → routing → module selection → emit `TriangleActivated { triangle_id }`. Add `TriangleActivated` event type and `TriangleActivatedQueue` resource. Register `activation_pass` in `StorytellerPlugin.build()` — runs after contamination is active, on 10-tick cadence.
|
||||
|
||||
**Blocked by:** #570, #571, #572.
|
||||
|
||||
### #580 — Debug console server
|
||||
|
||||
**What exists:** `PlayerAction` enum in `server/src/bridge/types.rs` has movement, interaction, save/load, pause, `TeleportToHub`. `PROTOCOL_VERSION = 17`.
|
||||
|
||||
**What to deliver:** New `PlayerAction::DebugCommand(DebugCommandKind)` variant. `DebugCommandKind` enum with 9 variants (see ticket description). `DebugResponsePayload` added as `Option<>` to `ObserverSnapshot`. Bump `PROTOCOL_VERSION` to 18 when adding the new variant. A `handle_debug_commands` system that only executes when a `DebugEnabled` resource is true (set at startup; default true for v0.1). The system routes each `DebugCommandKind` to the appropriate ECS query or mutation.
|
||||
|
||||
**Gotcha:** `PROTOCOL_VERSION` is checked by the client on every snapshot. Dudley must coordinate with Stig on the version bump — client #581 must update `protocol.gd` simultaneously to avoid mismatch errors.
|
||||
|
||||
### #584 — LOS boundary wall margin
|
||||
|
||||
**What exists:** Observer snapshot generation is in `server/src/perception/observer/mod.rs`. `VisibilitySector` enum in `bridge/types.rs` has existing variants. `WalkabilityMap` is available via `Res<WalkabilityMap>`.
|
||||
|
||||
**What to deliver:** After the standard visible tiles are computed, walk the LOS boundary and add wall tiles 1 tile beyond the cone. Use a new `VisibilitySector::BoundaryWall` variant (or reuse `LosObstructed` if that field already serves this purpose — check #514). Wall margin tiles must not affect the exploration/memory state — they are "seen now" only.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#576 (tile-type layer)
|
||||
→ #577 (tile format + loader) → #582 (Terminal tile map, visual)
|
||||
| → #583 (Last Shift + corridor maps, visual)
|
||||
→ #578 (chunk streaming)
|
||||
|
||||
#570 (EngagementRecord) ─┐
|
||||
#571 (MovementHistory) ─┤→ #579 (activation pass)
|
||||
#572 (lifecycle rules) ─┘
|
||||
|
||||
#580 (debug server) → #581 (debug client)
|
||||
|
||||
#584 (LOS wall margin server) → #585 (LOS wall margin client)
|
||||
```
|
||||
|
||||
Parallel tracks: spatial chain (#576→#577→#578), storyteller chain (#570-572→#579), debug chain (#580), LOS fix (#584). All four tracks are independent and can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "feat(server): chunk tile-type layer and location loader" \
|
||||
--description "body" --base main --head server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion (Server Criteria)
|
||||
|
||||
1. `cargo test` green on server branch — all existing tests pass, new tile-kind tests pass.
|
||||
2. Production startup (no gauntlet feature) boots with tile data loaded — WalkabilityMap populated from location YAML for The Terminal, The Last Shift, maintenance corridors.
|
||||
3. `EngagementRecord` increments correctly — perception/dialogue/monologue all write to it.
|
||||
4. `activation_pass()` fires after contamination and emits `TriangleActivated` for the highest-engagement NPC's triangle.
|
||||
5. `DebugCommand::SkipToContamination` advances simulation to `CONTAMINATION_DELAY_TICKS` and the response is returned in the snapshot.
|
||||
6. LOS wall margin — snapshot includes 1-tile wall data beyond the LOS cone boundary.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Sprint 23: Terrain — Visual Tasks
|
||||
|
||||
**Goal:** Build the walkable Sova world under the authored content, complete the storyteller engagement layer, and add the debug console — clearing the path to v0.1 integration in Sprint 24.
|
||||
|
||||
**Branch:** `visual`
|
||||
**Agents:** Araminta (art direction, tile authoring)
|
||||
|
||||
---
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #582 | Author tile map: The Terminal (logistics hub) | #577 (server tile format) |
|
||||
| #583 | Author tile maps: The Last Shift, maintenance corridors, and gate corridor | #577 (server tile format) |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full descriptions.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/content.md` — D-093 (Sova Transit District — **full spatial spec with tile dimensions, zone palette, corridor widths, zone-by-zone breakdown**)
|
||||
- `decisions/content.md` — D-025 (functional cluster: 15-40 tile connected spaces, internal sightlines, public/private zones)
|
||||
- `decisions/architecture.md` — D-094 (spatial hierarchy: chunk 32×32 sim tiles = 16×16 visual tiles at 2x retina, D-066), D-066 (dual-scale grid: 0.5m sim, 1m visual)
|
||||
- `decisions/architecture.md` — D-110 (z-levels: z=0 maintenance corridors, z=1 main structures, z=2 observation gallery)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions to Resolve Early
|
||||
|
||||
- **Tile format (day 1–2):** Server ticket #577 must publish the tile format before authoring begins. Coordinate with Dudley. The format is expected to be: `tiles:` array of strings, one char per tile. Do not author tile data until the format is confirmed and documented in `server/src/content/types.rs`.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Tile format primer
|
||||
|
||||
The server will confirm a format like:
|
||||
|
||||
```yaml
|
||||
tiles:
|
||||
- "WWWWWWWWWW"
|
||||
- "WFFFFFFFFFFW"
|
||||
- "WFFFFFFFFFFW"
|
||||
- "WWWWWWWWWW"
|
||||
```
|
||||
|
||||
Characters: `F`=floor (walkable), `W`=wall (blocks movement/LOS), `V`=void (empty space outside the location boundary), `R`=restricted (walkable but access-tier restricted). Left-to-right = +x, top-to-bottom = +y. Origin (0,0) = top-left corner of the tile_bounds bounding box.
|
||||
|
||||
Do not use any other characters until the server documents them.
|
||||
|
||||
### #582 — The Terminal tile map
|
||||
|
||||
**Source materials:**
|
||||
- `#311` (done): Spatial layout — The Terminal. Tile-level floor plan with sightline analysis. Sprint 12.
|
||||
- `D-093`: Terminal dimensions = **44×28 visual tiles**. Zone palette surface hex: `#7a8490`, fog tint: `#0d1520`.
|
||||
- Access tiers: main corridor = public, work floor = credentialed, restricted storage = restricted.
|
||||
- Key spatial features:
|
||||
- Scanner bays (entry — public)
|
||||
- Main corridor (chokepoint — high sightline value)
|
||||
- Manifest processing (work floor)
|
||||
- Break room (social site — NPC off-hours presence)
|
||||
- Supervisor office (window faces main corridor — LOS design requirement)
|
||||
- Restricted storage (smuggling ops — `R` tiles for access restriction)
|
||||
|
||||
**Output:** Complete `content/campaigns/main/systems/krenn/stations/sova/districts/transit/locations/the-terminal.yaml` with:
|
||||
- `canonical_id: krenn.sova.transit.location.the-terminal`
|
||||
- `display_name: "The Terminal"`
|
||||
- `tile_bounds: { x_min: 0, y_min: 0, x_max: 43, y_max: 27, z: 1 }` (z=1 per D-093)
|
||||
- `tiles: [...]` — the 44×28 grid
|
||||
- `sightlines: { open: false, notes: "supervisor office window faces main corridor; LOS critical for investigation" }`
|
||||
- `social_site: "logistics-hub"` (links to the template)
|
||||
|
||||
**Sightline requirement:** The supervisor office window must be placed so that the player standing in the main corridor has LOS to the office interior. This is investigatively critical — it is how the player first suspects the supervisor. Do not place it behind a wall that would block shadowcasting.
|
||||
|
||||
### #583 — The Last Shift, maintenance corridors, gate corridor
|
||||
|
||||
Three YAML files. All blocked on #577 tile format; can author all three in parallel once format is confirmed.
|
||||
|
||||
**The Last Shift (`the-last-shift.yaml`)**
|
||||
- Source: #312 (done), Sprint 12
|
||||
- D-093: **28×22 visual tiles + 6m east extension** (= ~6 visual tiles). Total footprint: ~34×22.
|
||||
- Zone palette: `#6b4018` (warm dark brown — converted maintenance area), fog tint: `#200c04`
|
||||
- Key features: long bar counter (high visibility — staff can see all tables), corner booth (observation premium — sees bar + card table + entrance + back room), scattered tables, news ticker display (`F` tiles), back room with alley exit (`R` or `F` tiles — navigable, but the back exit is the key traversal route)
|
||||
- z=1 (main structure per D-093)
|
||||
|
||||
**Maintenance corridors (`maintenance-corridors.yaml`)**
|
||||
- Source: #313 (done), Sprint 12
|
||||
- D-093: Corridor width = 2 visual tiles (maintenance standard). z=0 (Era 1, below main structures).
|
||||
- Zone palette: `#4e5054` (dark utilitarian grey), fog tint: `#101214`
|
||||
- Key features: restricted storage (shared with Terminal — use `R` tiles), maintenance hatch (connects z=0 to z=1 — note: cross-z connection is a future movement feature; for now just tile the spaces), transition corridor 40m between hub and bar district (~40 visual tiles length at 1m/tile)
|
||||
- Design principle (D-093 G-08): the ring operates here; all spaces must read as mundane maintenance unless you know what to look for. No obviously suspicious tile arrangements.
|
||||
|
||||
**Gate corridor (`gate-corridor.yaml`) — new file**
|
||||
- Source: D-093 gate cluster zone spec
|
||||
- D-093: Gate cluster **40×32 visual tiles**, z=1 main + z=2 observation gallery
|
||||
- Zone palette: `#b8bec4` (neutral cool — institutional grey), fog tint: `#0a1222`
|
||||
- Seven zones (author these as continuous tile grid, walls between zones):
|
||||
- Aperture chamber: 8×4 (restricted — `R` tiles)
|
||||
- Freight staging: 24×8 (private — use `F` tiles, access enforced by NPCs not tile kind)
|
||||
- Passenger arrival: 12×8 (semi-public — `F`)
|
||||
- Freight customs: 20×10 (semi-private, 3–5 lanes of 2-visual-tile width separated by wall stubs)
|
||||
- Pedestrian customs: 12×10 (semi-public, 3 lanes of 1-visual-tile width)
|
||||
- Gate concourse: 40×8 (public — wide open, 8vt corridor width per D-093)
|
||||
- Observation gallery: z=2, 32×10 (Commission-only — `R` tiles; separate tile_bounds entry with z:2)
|
||||
- D-093 cross-z LOS: gallery rail = transparent low wall. Model this as a separate z=2 tile grid with `R` floor tiles (navigable but restricted access). The LOS cross-z behavior is handled by the server; just provide the tile data.
|
||||
- Add `gate-corridor` to `content/campaigns/main/systems/krenn/stations/sova/districts/transit/district.yaml` locations list.
|
||||
|
||||
**Cross-z note:** Author two sections in the gate-corridor YAML if the format supports multiple `tile_bounds` (one at z=1, one at z=2). If the format only supports a single z-level per Location, author two separate files: `gate-ground.yaml` and `gate-gallery.yaml`. Confirm with Dudley when the format is published.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#577 (server: tile format published, day 1-2)
|
||||
→ #582 (Terminal) — can start same day format is confirmed
|
||||
→ #583 (Last Shift + maintenance + gate) — parallel with #582
|
||||
```
|
||||
|
||||
Both tile authoring tickets can run in parallel once #577 publishes the format. Expect 2–3 days of authoring work per ticket given the complexity of the gate corridor.
|
||||
|
||||
---
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "feat(visual): author tile maps for Sova Transit District" \
|
||||
--description "body" --base main --head visual
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion (Visual Criteria)
|
||||
|
||||
1. `the-terminal.yaml` has complete tile data — 44×28 grid, all zones represented, sightlines documented.
|
||||
2. `the-last-shift.yaml` has complete tile data — corner booth placement satisfies observation affordance.
|
||||
3. `maintenance-corridors.yaml` has complete tile data — z=0, maintenance aesthetic, restricted storage marked.
|
||||
4. `gate-corridor.yaml` (or equivalent) has complete tile data for all 7 zones including z=2 observation gallery.
|
||||
5. `district.yaml` locations list updated to include gate-corridor.
|
||||
6. Server can boot production startup and load tile data from all four locations without error.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.22
|
||||
version: 0.1.23
|
||||
repository: settled-reach
|
||||
codename: commonwealth
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.20"
|
||||
version = "0.1.22"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.22"
|
||||
version = "0.1.23"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Debug console command handler (#580).
|
||||
//!
|
||||
//! Processes `DebugCommandKind` variants buffered by `process_player_input`
|
||||
//! and writes `DebugResponsePayload` to `SnapshotBuffer.pending_debug_response`.
|
||||
//!
|
||||
//! Security: only executes when `DebugEnabled` resource is true.
|
||||
//! v0.1: enabled by default. Cannot be toggled mid-session.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::{
|
||||
DebugCommandKind, DebugEnabled, DebugResponsePayload, SnapshotBuffer,
|
||||
};
|
||||
use crate::content::template::TriangleState;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::{ContaminationActive, ContaminationEventQueue, CONTAMINATION_DELAY_TICKS};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buffer resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Buffer for debug commands forwarded from `process_player_input`.
|
||||
///
|
||||
/// Drained by `handle_debug_commands` each tick. Only the last command's
|
||||
/// response is delivered (debug console is request-response, not batched).
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct DebugCommandBuffer {
|
||||
commands: Vec<DebugCommandKind>,
|
||||
}
|
||||
|
||||
impl DebugCommandBuffer {
|
||||
pub fn push(&mut self, cmd: DebugCommandKind) {
|
||||
self.commands.push(cmd);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<DebugCommandKind> {
|
||||
std::mem::take(&mut self.commands)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Processes buffered debug commands and writes responses to the snapshot buffer.
|
||||
///
|
||||
/// Runs after `process_player_input`, before `compute_observer_snapshot`.
|
||||
/// Gated by `DebugEnabled` — if false, all commands are silently dropped.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn handle_debug_commands(
|
||||
debug_enabled: Option<Res<DebugEnabled>>,
|
||||
mut cmd_buffer: ResMut<DebugCommandBuffer>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
mut time: ResMut<SimulationTime>,
|
||||
mut contamination: Option<ResMut<ContaminationActive>>,
|
||||
mut contamination_queue: Option<ResMut<ContaminationEventQueue>>,
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut player_query: Query<(Entity, &mut TilePosition), With<PlayerCharacter>>,
|
||||
triangles: Query<(Entity, &TriangleState), With<ActiveSim>>,
|
||||
npcs: Query<(Entity, &TilePosition, Option<&NpcName>), (With<Npc>, With<ActiveSim>, Without<PlayerCharacter>)>,
|
||||
) {
|
||||
// Gate: debug must be enabled
|
||||
let enabled = debug_enabled.as_ref().map_or(false, |d| d.0);
|
||||
let commands = cmd_buffer.drain();
|
||||
if commands.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each command; last response wins (single debug_response per tick)
|
||||
for cmd in commands {
|
||||
let response = if !enabled {
|
||||
DebugResponsePayload {
|
||||
command: format!("{:?}", cmd),
|
||||
text: "Debug console is disabled.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
} else {
|
||||
match cmd {
|
||||
DebugCommandKind::AdvanceTicks(n) => {
|
||||
let old_tick = time.tick;
|
||||
time.tick = time.tick.saturating_add(n);
|
||||
DebugResponsePayload {
|
||||
command: format!("AdvanceTicks({})", n),
|
||||
text: format!(
|
||||
"Advanced {} ticks: {} -> {}",
|
||||
n, old_tick, time.tick
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
DebugCommandKind::SkipToContamination => {
|
||||
let is_active = contamination.as_ref().map(|c| c.0);
|
||||
match is_active {
|
||||
None => DebugResponsePayload {
|
||||
command: "SkipToContamination".to_string(),
|
||||
text: "Contamination system not available.".to_string(),
|
||||
success: false,
|
||||
},
|
||||
Some(true) => DebugResponsePayload {
|
||||
command: "SkipToContamination".to_string(),
|
||||
text: format!(
|
||||
"Contamination already active (fired at or before tick {}). Current tick: {}",
|
||||
time.tick, time.tick
|
||||
),
|
||||
success: true,
|
||||
},
|
||||
Some(false) if time.tick >= CONTAMINATION_DELAY_TICKS => {
|
||||
// Tick is already past the delay — advancing would be a no-op
|
||||
// or rewinding would break cooldowns. Report error instead.
|
||||
DebugResponsePayload {
|
||||
command: "SkipToContamination".to_string(),
|
||||
text: format!(
|
||||
"Current tick ({}) already past contamination delay ({}). Contamination should fire on next system run — use ForceContaminationActivate if it hasn't.",
|
||||
time.tick, CONTAMINATION_DELAY_TICKS
|
||||
),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
let old_tick = time.tick;
|
||||
time.tick = CONTAMINATION_DELAY_TICKS;
|
||||
DebugResponsePayload {
|
||||
command: "SkipToContamination".to_string(),
|
||||
text: format!(
|
||||
"Advanced tick to contamination threshold: {} -> {}. Contamination will fire on next system run.",
|
||||
old_tick, time.tick
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::TeleportToPosition { x, y, z } => {
|
||||
let target = TilePosition::new(x, y, z);
|
||||
// Validate target is walkable (if WalkabilityMap available)
|
||||
let walkable = walkability
|
||||
.as_ref()
|
||||
.map_or(true, |wm| wm.can_move_to(&target));
|
||||
if !walkable {
|
||||
DebugResponsePayload {
|
||||
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
|
||||
text: format!(
|
||||
"Target ({}, {}, {}) is not walkable. Player would be stuck in wall/void.",
|
||||
x, y, z
|
||||
),
|
||||
success: false,
|
||||
}
|
||||
} else if let Ok((_, mut pos)) = player_query.single_mut() {
|
||||
let old = (pos.x, pos.y, pos.z);
|
||||
pos.x = x;
|
||||
pos.y = y;
|
||||
pos.z = z;
|
||||
DebugResponsePayload {
|
||||
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
|
||||
text: format!(
|
||||
"Teleported player: ({}, {}, {}) -> ({}, {}, {})",
|
||||
old.0, old.1, old.2, x, y, z
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: format!("TeleportToPosition({}, {}, {})", x, y, z),
|
||||
text: "No player entity found.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::TeleportToLocation(ref name) => {
|
||||
// Location-based teleport requires ContentStore (future: resolve location
|
||||
// center from tile_bounds). For now, report unimplemented.
|
||||
DebugResponsePayload {
|
||||
command: format!("TeleportToLocation({})", name),
|
||||
text: format!(
|
||||
"TeleportToLocation not yet implemented (needs location tile_bounds from ContentStore). Use TeleportToPosition instead."
|
||||
),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
DebugCommandKind::ForceContaminationActivate => {
|
||||
if contamination.is_none() {
|
||||
DebugResponsePayload {
|
||||
command: "ForceContaminationActivate".to_string(),
|
||||
text: "Contamination system not available.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
} else if contamination.as_ref().unwrap().0 {
|
||||
DebugResponsePayload {
|
||||
command: "ForceContaminationActivate".to_string(),
|
||||
text: "Contamination already active.".to_string(),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
contamination.as_mut().unwrap().0 = true;
|
||||
// Also push an event so downstream systems react
|
||||
if let Some(ref mut queue) = contamination_queue {
|
||||
queue.push(crate::storyteller::ContaminationEvent {
|
||||
tick: time.tick,
|
||||
triangles_affected: 0, // no pressure delta applied — use SkipToContamination for that
|
||||
});
|
||||
}
|
||||
DebugResponsePayload {
|
||||
command: "ForceContaminationActivate".to_string(),
|
||||
text: format!(
|
||||
"Contamination force-activated at tick {}. Note: no tension delta applied (use SkipToContamination for full effect).",
|
||||
time.tick
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::ForceTriangleActivation(ref slug) => {
|
||||
// Future: match triangle by slug and force-activate.
|
||||
// Requires TriangleId slug lookup which isn't indexed yet.
|
||||
DebugResponsePayload {
|
||||
command: format!("ForceTriangleActivation({})", slug),
|
||||
text: "ForceTriangleActivation not yet implemented (needs TriangleId slug index).".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
DebugCommandKind::InspectNpc(wire_id) => {
|
||||
let stable_id = crate::knowledge::types::StableId(wire_id);
|
||||
if let Some(entity) = registry.to_entity(&stable_id) {
|
||||
if let Ok((_, pos, name)) = npcs.get(entity) {
|
||||
let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)");
|
||||
DebugResponsePayload {
|
||||
command: format!("InspectNpc({})", wire_id),
|
||||
text: format!(
|
||||
"NPC {} (stable_id={})\n Position: ({}, {}, {})\n Name: {}",
|
||||
entity, wire_id, pos.x, pos.y, pos.z, name_str
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: format!("InspectNpc({})", wire_id),
|
||||
text: format!("Entity {} exists but is not an Active-tier NPC.", wire_id),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: format!("InspectNpc({})", wire_id),
|
||||
text: format!("No entity found for stable_id {}.", wire_id),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
DebugCommandKind::ListTriangles => {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("=== Triangles ===".to_string());
|
||||
let mut count = 0u32;
|
||||
for (entity, state) in &triangles {
|
||||
count += 1;
|
||||
lines.push(format!(
|
||||
" {} | id={} | phase={:?} | tension={} | class={:?} | template={}",
|
||||
entity,
|
||||
state.triangle_id.0,
|
||||
state.phase,
|
||||
state.tension,
|
||||
state.classification,
|
||||
state.template_id.0,
|
||||
));
|
||||
}
|
||||
lines.push(format!("Total: {}", count));
|
||||
DebugResponsePayload {
|
||||
command: "ListTriangles".to_string(),
|
||||
text: lines.join("\n"),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
DebugCommandKind::ListPopulation => {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("=== Active-Tier NPCs ===".to_string());
|
||||
let mut count = 0u32;
|
||||
for (entity, pos, name) in &npcs {
|
||||
count += 1;
|
||||
let stable = registry.to_stable(entity);
|
||||
let name_str = name.map(|n| n.0.as_str()).unwrap_or("(unnamed)");
|
||||
lines.push(format!(
|
||||
" {} | sid={} | pos=({},{},{}) | {}",
|
||||
entity,
|
||||
stable.map(|s| s.0.to_string()).unwrap_or_else(|| "?".to_string()),
|
||||
pos.x, pos.y, pos.z,
|
||||
name_str,
|
||||
));
|
||||
}
|
||||
lines.push(format!("Total: {}", count));
|
||||
DebugResponsePayload {
|
||||
command: "ListPopulation".to_string(),
|
||||
text: lines.join("\n"),
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
DebugCommandKind::GetContaminationStatus => {
|
||||
if let Some(ref cont) = contamination {
|
||||
DebugResponsePayload {
|
||||
command: "GetContaminationStatus".to_string(),
|
||||
text: format!(
|
||||
"Contamination active: {}\nCurrent tick: {}\nContamination delay: {} ticks",
|
||||
cont.0, time.tick, CONTAMINATION_DELAY_TICKS
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
} else {
|
||||
DebugResponsePayload {
|
||||
command: "GetContaminationStatus".to_string(),
|
||||
text: "Contamination system not available.".to_string(),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!(command = %response.command, success = response.success, "Debug command processed");
|
||||
if buffer.pending_debug_response.is_some() {
|
||||
tracing::trace!(
|
||||
"Debug response overwritten by '{}' — earlier response dropped (last-wins per tick)",
|
||||
response.command
|
||||
);
|
||||
}
|
||||
buffer.pending_debug_response = Some(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
|
||||
fn setup_debug_world() -> (World, Schedule) {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<DebugCommandBuffer>();
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<ContaminationActive>();
|
||||
world.init_resource::<ContaminationEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(DebugEnabled(true));
|
||||
|
||||
// Spawn a player entity
|
||||
world.spawn((PlayerCharacter, TilePosition::new(10, 20, 0)));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(handle_debug_commands);
|
||||
(world, schedule)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_ticks_updates_simulation_time() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 100;
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::AdvanceTicks(50));
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 150);
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(resp.success);
|
||||
assert!(resp.text.contains("150"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_to_contamination_sets_tick() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::SkipToContamination);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, CONTAMINATION_DELAY_TICKS);
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(resp.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_moves_player() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
world.resource_mut::<DebugCommandBuffer>().push(
|
||||
DebugCommandKind::TeleportToPosition { x: 50, y: 60, z: 1 },
|
||||
);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
|
||||
let pos = q.single(&world).unwrap();
|
||||
assert_eq!((pos.x, pos.y, pos.z), (50, 60, 1));
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(resp.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_contamination_activates() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
assert!(!world.resource::<ContaminationActive>().0);
|
||||
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::ForceContaminationActivate);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.resource::<ContaminationActive>().0);
|
||||
assert!(!world.resource::<ContaminationEventQueue>().is_empty());
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(resp.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_disabled_rejects_commands() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
world.insert_resource(DebugEnabled(false));
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::GetContaminationStatus);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.text.contains("disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_contamination_status_reports_state() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::GetContaminationStatus);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(resp.success);
|
||||
assert!(resp.text.contains("false")); // not yet active
|
||||
assert!(resp.text.contains("42")); // current tick
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_commands_produces_no_response() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
schedule.run(&mut world);
|
||||
assert!(world.resource::<SnapshotBuffer>().pending_debug_response.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_rejects_unwalkable_target() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
map.set_walkable(&TilePosition::new(5, 5, 0), false);
|
||||
world.insert_resource(map);
|
||||
|
||||
world.resource_mut::<DebugCommandBuffer>().push(
|
||||
DebugCommandKind::TeleportToPosition { x: 5, y: 5, z: 0 },
|
||||
);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Player should NOT have moved
|
||||
let mut q = world.query_filtered::<&TilePosition, With<PlayerCharacter>>();
|
||||
let pos = q.single(&world).unwrap();
|
||||
assert_eq!((pos.x, pos.y, pos.z), (10, 20, 0), "player must not teleport to unwalkable tile");
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.text.contains("not walkable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_to_contamination_rejects_when_past_delay() {
|
||||
let (mut world, mut schedule) = setup_debug_world();
|
||||
// Set tick past the delay but contamination not yet active
|
||||
world.resource_mut::<SimulationTime>().tick = CONTAMINATION_DELAY_TICKS + 100;
|
||||
|
||||
world.resource_mut::<DebugCommandBuffer>().push(DebugCommandKind::SkipToContamination);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Tick should NOT have changed (no rewind)
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick,
|
||||
CONTAMINATION_DELAY_TICKS + 100,
|
||||
"tick must not rewind"
|
||||
);
|
||||
|
||||
let resp = world.resource::<SnapshotBuffer>().pending_debug_response.as_ref().unwrap();
|
||||
assert!(!resp.success);
|
||||
assert!(resp.text.contains("already past"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_enabled_defaults_to_debug_assertions() {
|
||||
let d = DebugEnabled::default();
|
||||
assert_eq!(d.0, cfg!(debug_assertions));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod debug;
|
||||
pub mod framing;
|
||||
pub mod local;
|
||||
pub mod tcp;
|
||||
@@ -221,12 +222,17 @@ impl Plugin for BridgePlugin {
|
||||
.init_resource::<ServerRunning>()
|
||||
.init_resource::<HandshakeState>()
|
||||
.init_resource::<SimErrorBuffer>()
|
||||
.init_resource::<debug::DebugCommandBuffer>()
|
||||
.init_resource::<DebugEnabled>()
|
||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
|
||||
debug::handle_debug_commands
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
crate::perception::observer::compute_visibility_geometry
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
crate::simulation::interaction::compute_nearby_interactions
|
||||
|
||||
@@ -192,6 +192,7 @@ fn sector_label(sector: VisibilitySector) -> &'static str {
|
||||
match sector {
|
||||
VisibilitySector::Forward => "Forward",
|
||||
VisibilitySector::Peripheral => "Periph",
|
||||
VisibilitySector::BoundaryWall => "BndWall",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +317,7 @@ mod tests {
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +456,7 @@ mod tests {
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
sim_errors: vec![],
|
||||
debug_response: None,
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -17,7 +17,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 = 17;
|
||||
pub const PROTOCOL_VERSION: u8 = 18;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
@@ -72,10 +72,11 @@ pub struct StartupMessage {
|
||||
/// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering).
|
||||
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
||||
/// sim_errors (#85, structured error reporting to client).
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 17.
|
||||
/// Protocol version for forward compatibility. Current: 18.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -191,6 +192,11 @@ pub struct ObserverSnapshot {
|
||||
/// Empty in normal operation. Client may display a warning toast.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sim_errors: Vec<SimError>,
|
||||
/// Debug console response for this tick (#580).
|
||||
/// Present when a debug command was processed. Client renders in
|
||||
/// the tilde console overlay. None in normal gameplay.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub debug_response: Option<DebugResponsePayload>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -336,6 +342,10 @@ pub enum VisibilitySector {
|
||||
Forward,
|
||||
/// Reduced range, dimmer rendering (side arcs)
|
||||
Peripheral,
|
||||
/// Wall tile 1 beyond the LOS boundary (#584).
|
||||
/// Gives the client wall data at the fog edge so fog composites over
|
||||
/// real geometry rather than empty space. Not stored in exploration/memory.
|
||||
BoundaryWall,
|
||||
}
|
||||
|
||||
/// A visible entity in the simulation
|
||||
@@ -473,6 +483,9 @@ pub enum PlayerAction {
|
||||
/// Client sends this when the player selects a save file to load.
|
||||
/// Server executes load_from_file and sends SaveLoadResultWire confirmation.
|
||||
LoadGame { path: String },
|
||||
/// Debug console command (#580). Only processed when `DebugEnabled` is true.
|
||||
/// Response delivered via `ObserverSnapshot.debug_response`.
|
||||
DebugCommand(DebugCommandKind),
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
@@ -492,6 +505,62 @@ impl PlayerAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug command variants for the in-game console (#580).
|
||||
///
|
||||
/// Sent via `PlayerAction::DebugCommand`. Only processed when `DebugEnabled`
|
||||
/// resource is true. Response returned in `ObserverSnapshot.debug_response`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DebugCommandKind {
|
||||
/// Fast-forward simulation by N ticks.
|
||||
AdvanceTicks(u64),
|
||||
/// Advance simulation to `CONTAMINATION_DELAY_TICKS` (skip early game).
|
||||
SkipToContamination,
|
||||
/// Move player to absolute tile position.
|
||||
TeleportToPosition { x: i32, y: i32, z: i32 },
|
||||
/// Move player to a named location's origin (e.g. "the-terminal").
|
||||
TeleportToLocation(String),
|
||||
/// Bypass contamination timer — fire `ContaminationActive` immediately.
|
||||
ForceContaminationActivate,
|
||||
/// Force-activate a triangle by its string slug identifier.
|
||||
ForceTriangleActivation(String),
|
||||
/// Dump NPC state: current routine, knowledge graph summary, relationships.
|
||||
InspectNpc(u64),
|
||||
/// Return all `TriangleState` entities with phase/tension/classification.
|
||||
ListTriangles,
|
||||
/// Return all Active-tier NPCs with tier, position, and name.
|
||||
ListPopulation,
|
||||
/// Return `ContaminationActive` status and current tick.
|
||||
GetContaminationStatus,
|
||||
}
|
||||
|
||||
/// Debug response payload included in `ObserverSnapshot` (#580).
|
||||
///
|
||||
/// Carries the result of a debug command as a human-readable text block
|
||||
/// plus structured data where useful. The client's debug console displays
|
||||
/// the `text` field directly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DebugResponsePayload {
|
||||
/// The command that produced this response (for client-side echo).
|
||||
pub command: String,
|
||||
/// Human-readable response text (multi-line, displayed in console).
|
||||
pub text: String,
|
||||
/// Whether the command succeeded.
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Whether the debug console is enabled (#580).
|
||||
///
|
||||
/// Set at server startup. Cannot be toggled mid-session via IPC.
|
||||
/// Defaults to true in debug builds, false in release builds.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct DebugEnabled(pub bool);
|
||||
|
||||
impl Default for DebugEnabled {
|
||||
fn default() -> Self {
|
||||
Self(cfg!(debug_assertions))
|
||||
}
|
||||
}
|
||||
|
||||
/// Available interaction verbs for a nearby entity (D-060, #404)
|
||||
/// Embedded in ObserverSnapshot.nearby_interactions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -804,6 +873,8 @@ pub struct SnapshotBuffer {
|
||||
pub snapshot: Option<ObserverSnapshot>,
|
||||
/// Pending save/load result, consumed once by `compute_observer_snapshot` (#553).
|
||||
pub pending_save_result: Option<SaveLoadResultWire>,
|
||||
/// Pending debug response, consumed once by `compute_observer_snapshot` (#580).
|
||||
pub pending_debug_response: Option<DebugResponsePayload>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -327,6 +327,131 @@ fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) {
|
||||
}
|
||||
|
||||
/// Check if a YAML file contains only comments and whitespace (stub file).
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile loading (#577)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap};
|
||||
|
||||
/// Parse a tile character into a TileKind.
|
||||
/// Returns `None` for unrecognized characters.
|
||||
fn parse_tile_char(ch: char) -> Option<TileKind> {
|
||||
match ch {
|
||||
'F' => Some(TileKind::Floor),
|
||||
'W' => Some(TileKind::Wall),
|
||||
'V' => Some(TileKind::Void),
|
||||
'R' => Some(TileKind::Restricted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load tile data from all locations in a ContentStore into a WalkabilityMap.
|
||||
///
|
||||
/// For each location that has both `tile_bounds` and `tiles`, parses the tile
|
||||
/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap.
|
||||
///
|
||||
/// Logs warnings for:
|
||||
/// - Row count mismatch vs tile_bounds height
|
||||
/// - Column count mismatch vs tile_bounds width
|
||||
/// - Unrecognized tile characters
|
||||
///
|
||||
/// Returns the number of locations that had tile data applied.
|
||||
pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 {
|
||||
let mut locations_loaded = 0u32;
|
||||
|
||||
for (_district_id, district) in &store.districts {
|
||||
for location in &district.locations {
|
||||
if apply_location_tiles(location, walkability) {
|
||||
locations_loaded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locations_loaded
|
||||
}
|
||||
|
||||
/// Apply tile data from a single Location to the WalkabilityMap.
|
||||
/// Returns true if tiles were applied, false if skipped.
|
||||
fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool {
|
||||
let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Guard: inverted bounds cause (y_max - y_min) to be negative, which wraps to
|
||||
// ~18 quintillion when cast to usize, silently writing tiles at garbage positions.
|
||||
if bounds.x_min > bounds.x_max || bounds.y_min > bounds.y_max {
|
||||
tracing::error!(
|
||||
"Location '{}': inverted tile_bounds (x: {}..={}, y: {}..={}), skipping",
|
||||
location.canonical_id,
|
||||
bounds.x_min, bounds.x_max,
|
||||
bounds.y_min, bounds.y_max,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_height = (bounds.y_max - bounds.y_min + 1) as usize;
|
||||
let expected_width = (bounds.x_max - bounds.x_min + 1) as usize;
|
||||
|
||||
if tiles.len() != expected_height {
|
||||
tracing::error!(
|
||||
"Location '{}': tile row count {} != expected height {} (from tile_bounds) — skipping to prevent walkability holes",
|
||||
location.canonical_id,
|
||||
tiles.len(),
|
||||
expected_height,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (row_idx, row) in tiles.iter().enumerate() {
|
||||
let y = bounds.y_min + row_idx as i32;
|
||||
|
||||
if row.len() != expected_width {
|
||||
tracing::error!(
|
||||
"Location '{}' row {}: length {} != expected width {} — skipping row to prevent walkability holes",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
row.len(),
|
||||
expected_width,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (col_idx, ch) in row.chars().enumerate() {
|
||||
let x = bounds.x_min + col_idx as i32;
|
||||
let pos = TilePosition::new(x, y, bounds.z);
|
||||
|
||||
match parse_tile_char(ch) {
|
||||
Some(kind) => {
|
||||
let walkable = matches!(kind, TileKind::Floor);
|
||||
walkability.set_walkable(&pos, walkable);
|
||||
walkability.set_tile_kind(&pos, kind);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"Location '{}' row {} col {}: unrecognized tile char '{}'",
|
||||
location.canonical_id,
|
||||
row_idx,
|
||||
col_idx,
|
||||
ch,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Loaded tiles for location '{}': {}x{} at ({},{}) z={}",
|
||||
location.canonical_id,
|
||||
expected_width,
|
||||
expected_height,
|
||||
bounds.x_min,
|
||||
bounds.y_min,
|
||||
bounds.z,
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn is_comment_only_file(path: &Path) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return false;
|
||||
@@ -618,4 +743,193 @@ pools:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tile loading tests (#577)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_location_with_tiles(tiles: Vec<&str>) -> Location {
|
||||
Location {
|
||||
canonical_id: "test-loc".to_string(),
|
||||
display_name: "Test Location".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 0,
|
||||
y_min: 0,
|
||||
x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1),
|
||||
y_max: tiles.len() as i32 - 1,
|
||||
z: 0,
|
||||
}),
|
||||
tiles: Some(tiles.iter().map(|s| s.to_string()).collect()),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tile_char_all_kinds() {
|
||||
assert_eq!(parse_tile_char('F'), Some(TileKind::Floor));
|
||||
assert_eq!(parse_tile_char('W'), Some(TileKind::Wall));
|
||||
assert_eq!(parse_tile_char('V'), Some(TileKind::Void));
|
||||
assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted));
|
||||
assert_eq!(parse_tile_char('X'), None);
|
||||
assert_eq!(parse_tile_char(' '), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_stamps_walkability() {
|
||||
let loc = make_location_with_tiles(vec![
|
||||
"FWF",
|
||||
"FFF",
|
||||
"WFW",
|
||||
]);
|
||||
let mut map = WalkabilityMap::new(4, 4, 1);
|
||||
|
||||
let applied = apply_location_tiles(&loc, &mut map);
|
||||
assert!(applied);
|
||||
|
||||
// Row 0: F W F
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(2, 0, 0)));
|
||||
|
||||
// Row 1: F F F
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(2, 1, 0)));
|
||||
|
||||
// Row 2: W F W
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 2, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 2, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(2, 2, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_stamps_tile_kind() {
|
||||
let loc = make_location_with_tiles(vec![
|
||||
"FWVR",
|
||||
]);
|
||||
let mut map = WalkabilityMap::new(4, 1, 1);
|
||||
|
||||
apply_location_tiles(&loc, &mut map);
|
||||
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_with_offset() {
|
||||
let loc = Location {
|
||||
canonical_id: "offset-loc".to_string(),
|
||||
display_name: "Offset".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 10,
|
||||
y_min: 20,
|
||||
x_max: 12,
|
||||
y_max: 21,
|
||||
z: 0,
|
||||
}),
|
||||
tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(32, 32, 1);
|
||||
|
||||
apply_location_tiles(&loc, &mut map);
|
||||
|
||||
// (10,20) = F, (11,20) = W, (12,20) = F
|
||||
assert!(map.can_move_to(&TilePosition::new(10, 20, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(11, 20, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(12, 20, 0)));
|
||||
|
||||
// (10,21) = W, (11,21) = F, (12,21) = W
|
||||
assert!(!map.can_move_to(&TilePosition::new(10, 21, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(11, 21, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(12, 21, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_skips_without_tiles() {
|
||||
let loc = Location {
|
||||
canonical_id: "no-tiles".to_string(),
|
||||
display_name: "No Tiles".to_string(),
|
||||
description: None,
|
||||
tile_bounds: Some(TileBounds {
|
||||
x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0,
|
||||
}),
|
||||
tiles: None,
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
assert!(!apply_location_tiles(&loc, &mut map));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_location_tiles_skips_without_bounds() {
|
||||
let loc = Location {
|
||||
canonical_id: "no-bounds".to_string(),
|
||||
display_name: "No Bounds".to_string(),
|
||||
description: None,
|
||||
tile_bounds: None,
|
||||
tiles: Some(vec!["FFF".to_string()]),
|
||||
sightlines: None,
|
||||
ambient_sound: None,
|
||||
social_site: None,
|
||||
};
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
assert!(!apply_location_tiles(&loc, &mut map));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_location_tiles_from_store() {
|
||||
let mut store = ContentStore::default();
|
||||
let mut district = DistrictContent::default();
|
||||
district.locations.push(make_location_with_tiles(vec![
|
||||
"FW",
|
||||
"WF",
|
||||
]));
|
||||
store.districts.insert("test".to_string(), district);
|
||||
|
||||
let mut map = WalkabilityMap::new(4, 4, 1);
|
||||
let count = load_location_tiles(&store, &mut map);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(1, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 1, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(1, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn location_yaml_with_tiles_deserializes() {
|
||||
let yaml = r#"
|
||||
canonical_id: test-room
|
||||
display_name: "Test Room"
|
||||
tile_bounds:
|
||||
x_min: 5
|
||||
y_min: 10
|
||||
x_max: 9
|
||||
y_max: 12
|
||||
z: 0
|
||||
tiles:
|
||||
- "FFFFF"
|
||||
- "FWWWF"
|
||||
- "FFFFF"
|
||||
"#;
|
||||
let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse");
|
||||
assert_eq!(loc.canonical_id, "test-room");
|
||||
assert!(loc.tiles.is_some());
|
||||
let tiles = loc.tiles.unwrap();
|
||||
assert_eq!(tiles.len(), 3);
|
||||
assert_eq!(tiles[0], "FFFFF");
|
||||
assert_eq!(tiles[1], "FWWWF");
|
||||
assert_eq!(tiles[2], "FFFFF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,15 @@ fn load_and_spawn_content(world: &mut World) {
|
||||
let result = spawn::spawn_content(world, &store);
|
||||
tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned);
|
||||
|
||||
// Stamp location tile data onto WalkabilityMap (#577)
|
||||
if world.contains_resource::<crate::simulation::movement::WalkabilityMap>() {
|
||||
let mut walkability = world.resource_mut::<crate::simulation::movement::WalkabilityMap>();
|
||||
let tiles_loaded = loader::load_location_tiles(&store, &mut walkability);
|
||||
if tiles_loaded > 0 {
|
||||
tracing::info!("Loaded tile data for {} locations", tiles_loaded);
|
||||
}
|
||||
}
|
||||
|
||||
// Build line pool index
|
||||
let index = line_pool::LinePoolIndex::build(&store);
|
||||
tracing::info!(
|
||||
|
||||
@@ -33,6 +33,7 @@ use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::DayPhase;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
// #166 — Template-to-instance mapping
|
||||
use rand::Rng as _;
|
||||
@@ -133,6 +134,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
// Mood state — drives Layer 4 dialogue selection and monologue tone (#323)
|
||||
entity_commands.insert(npc::mood::MoodState::default());
|
||||
|
||||
// Engagement metrics — storyteller activation scoring (#570, #162)
|
||||
entity_commands.insert(EngagementRecord::default());
|
||||
|
||||
// Axis 1: Want
|
||||
if let Some(want) = &profile.want {
|
||||
if let Some(kind) = parse_want_kind(&want.primary) {
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct Discovery {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DistrictMeta {
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub locations: Vec<String>,
|
||||
@@ -394,6 +395,20 @@ pub struct Location {
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tile_bounds: Option<TileBounds>,
|
||||
/// Tile layout for this location (#577).
|
||||
///
|
||||
/// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y.
|
||||
/// Each character maps to a server-side TileKind:
|
||||
/// `F` = Floor (walkable, open space)
|
||||
/// `W` = Wall (solid obstacle, blocks movement and LOS)
|
||||
/// `V` = Void (out-of-bounds / unloaded)
|
||||
/// `R` = Restricted (blocked but traversable by specific entities)
|
||||
///
|
||||
/// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`.
|
||||
/// Requires `tile_bounds` to be set. Row count must equal
|
||||
/// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`.
|
||||
#[serde(default)]
|
||||
pub tiles: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub sightlines: Option<Sightlines>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -300,6 +300,7 @@ fn send_panic_error(app: &App, panic_msg: &str) {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![SimError {
|
||||
kind: SimErrorKind::Panic,
|
||||
message: format!("Simulation panic: {}", panic_msg),
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::knowledge::{
|
||||
use crate::perception::cognitive_delay::{CognitiveDelay, PendingRecognition, RecognitionTrigger};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
/// Emit knowledge events for entities entering/leaving the observer's LOS.
|
||||
///
|
||||
@@ -29,6 +30,7 @@ pub fn emit_observation_events(
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
entity_positions: Query<&TilePosition>,
|
||||
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
||||
mut engagement_query: Query<&mut EngagementRecord>,
|
||||
) {
|
||||
let Some(snapshot) = &buffer.snapshot else {
|
||||
return;
|
||||
@@ -61,6 +63,11 @@ pub fn emit_observation_events(
|
||||
continue;
|
||||
};
|
||||
|
||||
// Increment observation time for engagement tracking (#570)
|
||||
if let Ok(mut record) = engagement_query.get_mut(entity) {
|
||||
record.observation_time_ticks += 1;
|
||||
}
|
||||
|
||||
// Get tile position for knowledge tracking
|
||||
let Ok(pos) = entity_positions.get(entity) else {
|
||||
continue;
|
||||
|
||||
@@ -392,6 +392,9 @@ pub fn compute_observer_snapshot(
|
||||
// Consume pending save/load result for this tick (#553).
|
||||
let save_result = buffer.pending_save_result.take();
|
||||
|
||||
// Consume pending debug response for this tick (#580).
|
||||
let debug_response = buffer.pending_debug_response.take();
|
||||
|
||||
// Drain triangle crisis events (#250) and convert to wire format.
|
||||
// Drain triangle crisis events (#250) and filter role_assignments against
|
||||
// observer KG (D-010 principle 2: information boundaries are universal).
|
||||
@@ -464,6 +467,7 @@ pub fn compute_observer_snapshot(
|
||||
triangle_crisis_events,
|
||||
state_hash,
|
||||
sim_errors,
|
||||
debug_response,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,23 @@ impl PerceptionQuery for NaturalVision {
|
||||
.map(|&(x, y, sector)| ((x, y), sector))
|
||||
.collect();
|
||||
|
||||
// --- Boundary wall margin pass (#584) ---
|
||||
// Walk the LOS boundary and include non-walkable tiles 1 tile beyond.
|
||||
// This gives the client wall geometry at the fog edge.
|
||||
let boundary_walls = compute_boundary_walls(&visible_positions, walkability, z);
|
||||
for (bx, by) in &boundary_walls {
|
||||
visible_tiles.push(VisibleTile {
|
||||
x: *bx,
|
||||
y: *by,
|
||||
z,
|
||||
visibility: VisibilitySector::BoundaryWall,
|
||||
tile_kind: TileKind::Wall,
|
||||
zone_id: None,
|
||||
});
|
||||
}
|
||||
// Re-sort after adding boundary walls
|
||||
visible_tiles.sort_by_key(|t| (t.x, t.y));
|
||||
|
||||
VisibilityGeometry {
|
||||
visible_tiles,
|
||||
visible_positions,
|
||||
@@ -105,6 +122,37 @@ impl PerceptionQuery for NaturalVision {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute wall tiles 1 tile beyond the LOS boundary (#584).
|
||||
///
|
||||
/// For each tile on the boundary of the visible set (has at least one
|
||||
/// 4-neighbor outside the set), check each non-visible neighbor.
|
||||
/// If that neighbor is not walkable, include it as a boundary wall.
|
||||
///
|
||||
/// Returns deduplicated (x, y) positions of wall tiles to add.
|
||||
fn compute_boundary_walls(
|
||||
visible_positions: &BTreeSet<(i32, i32)>,
|
||||
walkability: &WalkabilityMap,
|
||||
z: i32,
|
||||
) -> Vec<(i32, i32)> {
|
||||
const NEIGHBORS: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)];
|
||||
let mut walls = BTreeSet::new();
|
||||
|
||||
for &(x, y) in visible_positions {
|
||||
for (dx, dy) in NEIGHBORS {
|
||||
let nx = x + dx;
|
||||
let ny = y + dy;
|
||||
if !visible_positions.contains(&(nx, ny)) {
|
||||
let pos = TilePosition::new(nx, ny, z);
|
||||
if !walkability.can_move_to(&pos) {
|
||||
walls.insert((nx, ny));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walls.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Resource wrapping the active perception mode (D-017).
|
||||
/// Defaults to NaturalVision. Swap this resource to change perception modes.
|
||||
#[derive(Resource)]
|
||||
@@ -115,3 +163,103 @@ impl Default for ActivePerceptionMode {
|
||||
Self(Box::new(NaturalVision))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a small walkability map with walls around the edges.
|
||||
/// Layout (5x5, z=0):
|
||||
/// W W W W W
|
||||
/// W F F F W
|
||||
/// W F F F W
|
||||
/// W F F F W
|
||||
/// W W W W W
|
||||
fn make_walled_map() -> WalkabilityMap {
|
||||
let mut map = WalkabilityMap::new(5, 5, 1);
|
||||
// All tiles start walkable (floor). Set border to non-walkable (wall).
|
||||
for x in 0..5 {
|
||||
map.set_walkable(&TilePosition::new(x, 0, 0), false);
|
||||
map.set_walkable(&TilePosition::new(x, 4, 0), false);
|
||||
}
|
||||
for y in 0..5 {
|
||||
map.set_walkable(&TilePosition::new(0, y, 0), false);
|
||||
map.set_walkable(&TilePosition::new(4, y, 0), false);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_walls_include_adjacent_walls() {
|
||||
// Visible set: just the center tile (2,2)
|
||||
let visible: BTreeSet<(i32, i32)> = [(2, 2)].into_iter().collect();
|
||||
let map = make_walled_map();
|
||||
let walls = compute_boundary_walls(&visible, &map, 0);
|
||||
|
||||
// All 4 neighbors of (2,2) are floor tiles (walkable), so no walls.
|
||||
// This verifies we don't add walkable tiles as boundary walls.
|
||||
assert!(walls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_walls_found_at_edge() {
|
||||
// Visible set: tiles along the north interior edge (y=1)
|
||||
let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1), (3, 1)].into_iter().collect();
|
||||
let map = make_walled_map();
|
||||
let walls = compute_boundary_walls(&visible, &map, 0);
|
||||
|
||||
// North neighbors (y=0) are all walls: (1,0), (2,0), (3,0)
|
||||
// Also (0,1) is a wall (west of (1,1)) and (4,1) (east of (3,1))
|
||||
assert!(walls.contains(&(1, 0)));
|
||||
assert!(walls.contains(&(2, 0)));
|
||||
assert!(walls.contains(&(3, 0)));
|
||||
assert!(walls.contains(&(0, 1)));
|
||||
assert!(walls.contains(&(4, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_walls_deduplicated() {
|
||||
// Two adjacent visible tiles share a wall neighbor
|
||||
let visible: BTreeSet<(i32, i32)> = [(1, 1), (2, 1)].into_iter().collect();
|
||||
let map = make_walled_map();
|
||||
let walls = compute_boundary_walls(&visible, &map, 0);
|
||||
|
||||
// Count how many times (1,0) appears — should be exactly 1 (deduplicated)
|
||||
let count = walls.iter().filter(|&&(x, y)| x == 1 && y == 0).count();
|
||||
assert_eq!(count, 1, "boundary walls should be deduplicated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_walls_not_in_visible_positions() {
|
||||
// Verify the full NaturalVision pipeline produces BoundaryWall tiles
|
||||
// that are NOT in visible_positions.
|
||||
let map = make_walled_map();
|
||||
let nv = NaturalVision;
|
||||
let pos = TilePosition::new(2, 2, 0);
|
||||
let geometry = nv.compute_geometry(&pos, FacingDirection::North, &map);
|
||||
|
||||
let boundary_tiles: Vec<_> = geometry
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.filter(|t| t.visibility == VisibilitySector::BoundaryWall)
|
||||
.collect();
|
||||
|
||||
// There should be boundary wall tiles (the 5x5 map has walls at edges)
|
||||
assert!(!boundary_tiles.is_empty(), "expected boundary wall tiles");
|
||||
|
||||
// None of the boundary wall tiles should be in visible_positions
|
||||
for tile in &boundary_tiles {
|
||||
assert!(
|
||||
!geometry.visible_positions.contains(&(tile.x, tile.y)),
|
||||
"BoundaryWall tile ({}, {}) should NOT be in visible_positions",
|
||||
tile.x,
|
||||
tile.y
|
||||
);
|
||||
}
|
||||
|
||||
// All boundary wall tiles should have TileKind::Wall
|
||||
for tile in &boundary_tiles {
|
||||
assert_eq!(tile.tile_kind, TileKind::Wall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Chunk streaming system (#578, D-012).
|
||||
//!
|
||||
//! Loads chunks near the player and unloads distant chunks based on a
|
||||
//! configurable radius. For v0.1 the radius covers the entire hand-authored
|
||||
//! district (256×256 visual tiles = 8×8 chunks of 32 tiles), so all chunks
|
||||
//! remain loaded. The architecture supports future per-demand loading (v0.3+).
|
||||
//!
|
||||
//! The system runs on a configurable tick cadence (default: every 10 ticks).
|
||||
//! It queries the player's TilePosition, computes which chunks should be
|
||||
//! loaded (Chebyshev distance ≤ radius from the player's chunk), and
|
||||
//! loads/unloads accordingly.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::simulation::movement::{ChunkCoord, PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// How many chunks around the player to keep loaded (Chebyshev distance).
|
||||
///
|
||||
/// Default: 8, which covers the full v0.1 district (256×256 = 8×8 chunks).
|
||||
/// For v0.3+ borderless generation, set to 3-4 for memory-bounded streaming.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ChunkLoadRadius {
|
||||
pub radius: i32,
|
||||
}
|
||||
|
||||
impl Default for ChunkLoadRadius {
|
||||
fn default() -> Self {
|
||||
Self { radius: 8 }
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the streaming system runs, in simulation ticks.
|
||||
///
|
||||
/// Default: 10 ticks (one game-minute at D-031 cadence).
|
||||
/// Lower values increase responsiveness but add per-tick overhead.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ChunkStreamingCadence {
|
||||
pub ticks: u64,
|
||||
}
|
||||
|
||||
impl Default for ChunkStreamingCadence {
|
||||
fn default() -> Self {
|
||||
Self { ticks: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunk streaming system — loads/unloads chunks around the player position.
|
||||
///
|
||||
/// Runs on cadence (every `ChunkStreamingCadence.ticks` simulation ticks).
|
||||
/// Computes the set of chunks within `ChunkLoadRadius` of the player's
|
||||
/// current chunk (Chebyshev distance), loads missing chunks, and unloads
|
||||
/// chunks that are now out of range.
|
||||
///
|
||||
/// For v0.1, radius=8 covers the entire district so nothing ever unloads.
|
||||
/// For v0.3+, the generator fills newly loaded chunks with terrain data.
|
||||
pub fn chunk_streaming(
|
||||
time: Res<SimulationTime>,
|
||||
cadence: Res<ChunkStreamingCadence>,
|
||||
radius: Res<ChunkLoadRadius>,
|
||||
walkability: Option<ResMut<WalkabilityMap>>,
|
||||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||
) {
|
||||
let Some(mut walkability) = walkability else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Cadence gate — only run every N ticks
|
||||
if cadence.ticks > 0 && time.tick % cadence.ticks != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(player_pos) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let player_chunk = player_pos.chunk_coord();
|
||||
let r = radius.radius;
|
||||
|
||||
// Load chunks within radius that aren't already loaded
|
||||
let mut loaded = 0u32;
|
||||
for cx in (player_chunk.cx - r)..=(player_chunk.cx + r) {
|
||||
for cy in (player_chunk.cy - r)..=(player_chunk.cy + r) {
|
||||
let coord = ChunkCoord {
|
||||
cx,
|
||||
cy,
|
||||
z: player_chunk.z,
|
||||
};
|
||||
if walkability.load_chunk(coord) {
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unload chunks outside radius
|
||||
let mut unloaded = 0u32;
|
||||
let to_check = walkability.loaded_chunk_coords();
|
||||
for coord in to_check {
|
||||
// Only manage chunks on the player's z-level
|
||||
if coord.z != player_chunk.z {
|
||||
continue;
|
||||
}
|
||||
let dx = (coord.cx - player_chunk.cx).abs();
|
||||
let dy = (coord.cy - player_chunk.cy).abs();
|
||||
if dx > r || dy > r {
|
||||
walkability.unload_chunk(&coord);
|
||||
unloaded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if loaded > 0 || unloaded > 0 {
|
||||
tracing::debug!(
|
||||
"Chunk streaming: loaded {}, unloaded {} (player chunk: ({},{},{}), radius: {})",
|
||||
loaded,
|
||||
unloaded,
|
||||
player_chunk.cx,
|
||||
player_chunk.cy,
|
||||
player_chunk.z,
|
||||
r,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
|
||||
fn setup_streaming_world(
|
||||
radius: i32,
|
||||
cadence: u64,
|
||||
player_pos: TilePosition,
|
||||
map_width: i32,
|
||||
map_height: i32,
|
||||
) -> (World, Schedule) {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(ChunkLoadRadius { radius });
|
||||
world.insert_resource(ChunkStreamingCadence { ticks: cadence });
|
||||
world.insert_resource(WalkabilityMap::new(map_width, map_height, 1));
|
||||
world.spawn((PlayerCharacter, player_pos));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(chunk_streaming);
|
||||
(world, schedule)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_radius_covers_v01_district() {
|
||||
// 256×256 visual district = 8×8 chunks of 32 tiles each.
|
||||
// Player at center (128, 128). Default radius=8.
|
||||
// All original 64 chunks should still be loaded after streaming runs
|
||||
// (streaming may also create empty chunks beyond the district boundary).
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
8,
|
||||
1, // run every tick
|
||||
TilePosition::new(128, 128, 0),
|
||||
256,
|
||||
256,
|
||||
);
|
||||
|
||||
// Initial state: WalkabilityMap::new(256, 256, 1) creates 8×8 = 64 chunks
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 64);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// All original chunks (0..8, 0..8) must still be loaded — nothing unloaded
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
for cx in 0..8 {
|
||||
for cy in 0..8 {
|
||||
assert!(
|
||||
wm.has_chunk(&ChunkCoord { cx, cy, z: 0 }),
|
||||
"chunk ({},{}) should still be loaded",
|
||||
cx,
|
||||
cy,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Total count ≥ 64 (streaming may also load chunks beyond district boundary)
|
||||
assert!(wm.chunk_count() >= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_radius_unloads_distant_chunks() {
|
||||
// Start with a 5×5 chunk map (160×160 tiles), player at center.
|
||||
// Use radius=1 so only 3×3=9 chunks around the player are kept.
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
1,
|
||||
TilePosition::new(80, 80, 0), // chunk (2,2) — center of 5×5
|
||||
160,
|
||||
160,
|
||||
);
|
||||
|
||||
// Initial: 5×5 = 25 chunks
|
||||
let initial_count = world.resource::<WalkabilityMap>().chunk_count();
|
||||
assert_eq!(initial_count, 25);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// After streaming: only 3×3 = 9 chunks around player chunk (2,2)
|
||||
let after_count = world.resource::<WalkabilityMap>().chunk_count();
|
||||
assert_eq!(after_count, 9, "expected 3×3 chunks within radius=1");
|
||||
|
||||
// Verify the player's chunk is still loaded
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
|
||||
// Corner chunks should be unloaded
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 4, cy: 4, z: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_movement_loads_new_chunks() {
|
||||
// Start with radius=1, player at (16, 16) → chunk (0,0).
|
||||
// Map is 3×3 chunks (96×96 tiles).
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
1,
|
||||
TilePosition::new(16, 16, 0), // chunk (0,0)
|
||||
96,
|
||||
96,
|
||||
);
|
||||
|
||||
// Run streaming — unloads distant chunks
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Only chunks (0,0), (0,1), (1,0), (1,1) should be loaded
|
||||
// (radius=1 from chunk (0,0): cx ∈ [-1..1], cy ∈ [-1..1],
|
||||
// but negative coords weren't in the original map.
|
||||
// So loaded: (0,0) and its positive neighbors within range)
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 1, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 }));
|
||||
// Chunk (2,2) should be unloaded (distance > 1 from (0,0))
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
|
||||
|
||||
// Move player to chunk (2,2)
|
||||
let mut q = world.query_filtered::<&mut TilePosition, With<PlayerCharacter>>();
|
||||
let mut pos = q.single_mut(&mut world).unwrap();
|
||||
pos.x = 80;
|
||||
pos.y = 80;
|
||||
|
||||
// Advance tick so cadence gate passes
|
||||
world.resource_mut::<SimulationTime>().tick = 1;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Now chunk (2,2) and its neighbors should be loaded
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 2, z: 0 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 1, z: 0 }));
|
||||
// And chunk (0,0) should now be unloaded (distance 2 from (2,2))
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadence_gate_skips_intermediate_ticks() {
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
10, // run every 10 ticks
|
||||
TilePosition::new(80, 80, 0),
|
||||
160,
|
||||
160,
|
||||
);
|
||||
|
||||
// tick=0 → runs (0 % 10 == 0)
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
|
||||
|
||||
// Reload all chunks to simulate "something loads chunks back"
|
||||
world.insert_resource(WalkabilityMap::new(160, 160, 1));
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 25);
|
||||
|
||||
// tick=5 → should NOT run (5 % 10 != 0)
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(
|
||||
world.resource::<WalkabilityMap>().chunk_count(),
|
||||
25,
|
||||
"should not have run at tick 5"
|
||||
);
|
||||
|
||||
// tick=10 → should run (10 % 10 == 0)
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cadence_runs_every_tick() {
|
||||
let (mut world, mut schedule) = setup_streaming_world(
|
||||
1,
|
||||
0, // cadence=0 means run every tick
|
||||
TilePosition::new(80, 80, 0),
|
||||
160,
|
||||
160,
|
||||
);
|
||||
|
||||
// tick=0, cadence=0: condition is `0 > 0 && ...` which is false → runs
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_z_levels_untouched() {
|
||||
// Create a map with 2 z-levels. Player on z=0 with radius=0 (only own chunk).
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(ChunkLoadRadius { radius: 0 });
|
||||
world.insert_resource(ChunkStreamingCadence { ticks: 1 });
|
||||
|
||||
// 2×2 chunks on 2 z-levels = 8 chunks total
|
||||
world.insert_resource(WalkabilityMap::new(64, 64, 2));
|
||||
world.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(chunk_streaming);
|
||||
|
||||
// Before: 8 chunks (2×2×2)
|
||||
assert_eq!(world.resource::<WalkabilityMap>().chunk_count(), 8);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// After: z=0 should have only 1 chunk (player's own), z=1 untouched (2×2=4)
|
||||
// Total: 1 + 4 = 5
|
||||
let wm = world.resource::<WalkabilityMap>();
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 }));
|
||||
assert!(!wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 }));
|
||||
// z=1 chunks all still there
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 1 }));
|
||||
assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 1 }));
|
||||
assert_eq!(wm.chunk_count(), 5);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use rand::Rng;
|
||||
|
||||
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
|
||||
use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName};
|
||||
use crate::storyteller::EngagementRecord;
|
||||
use crate::content::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
@@ -449,6 +450,7 @@ pub fn process_talk_interaction(
|
||||
Option<&NpcColorIndex>,
|
||||
Option<&KnowledgeGraph>,
|
||||
)>,
|
||||
mut engagement_query: Query<&mut EngagementRecord>,
|
||||
) {
|
||||
let Some(line_pool) = line_pool else {
|
||||
return;
|
||||
@@ -583,6 +585,11 @@ pub fn process_talk_interaction(
|
||||
started_tick: time.tick,
|
||||
});
|
||||
|
||||
// Engagement tracking (#570): increment conversation count for this NPC
|
||||
if let Ok(mut record) = engagement_query.get_mut(target) {
|
||||
record.conversation_count += 1;
|
||||
}
|
||||
|
||||
// Trust progression (#324): successful talk warms the NPC
|
||||
trust_queue.push(TrustEvent::TalkCompleted {
|
||||
npc: target,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
|
||||
|
||||
use crate::bridge::debug::DebugCommandBuffer;
|
||||
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
@@ -98,6 +99,7 @@ pub fn process_player_input(
|
||||
reset_triggers: Query<&RoomResetTrigger>,
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
@@ -119,6 +121,7 @@ pub fn process_player_input(
|
||||
| PlayerAction::TeleportToHub
|
||||
| PlayerAction::SaveGame { .. }
|
||||
| PlayerAction::LoadGame { .. }
|
||||
| PlayerAction::DebugCommand(_)
|
||||
)
|
||||
{
|
||||
continue;
|
||||
@@ -364,6 +367,13 @@ pub fn process_player_input(
|
||||
tracing::warn!("LoadGame received but SaveLoadPending resource not registered");
|
||||
}
|
||||
}
|
||||
PlayerAction::DebugCommand(cmd) => {
|
||||
if let Some(ref mut buf) = debug_cmd_buffer {
|
||||
buf.push(cmd);
|
||||
} else {
|
||||
tracing::warn!("DebugCommand received but DebugCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod chunk_streaming;
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
@@ -50,6 +51,8 @@ impl Plugin for SimulationPlugin {
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.init_resource::<sound::SoundEventQueue>()
|
||||
.init_resource::<spatial::NaiveSpatialIndex>()
|
||||
.init_resource::<chunk_streaming::ChunkLoadRadius>()
|
||||
.init_resource::<chunk_streaming::ChunkStreamingCadence>()
|
||||
.init_resource::<follow::FollowEndEventQueue>()
|
||||
.init_resource::<monologue::PostConversationQueue>()
|
||||
.init_resource::<poi_discovery::PoiDiscoveryEventQueue>()
|
||||
@@ -127,6 +130,12 @@ impl Plugin for SimulationPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
zone::detect_zone_crossings.after(movement::validate_movement),
|
||||
)
|
||||
// Chunk streaming (#578, D-012) — loads/unloads chunks around the player.
|
||||
// Runs before input processing so chunks are available for the current tick.
|
||||
.add_systems(
|
||||
Update,
|
||||
chunk_streaming::chunk_streaming.before(input::process_player_input),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
|
||||
@@ -16,10 +16,12 @@ use rand::Rng;
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::content::ContentStoreResource;
|
||||
use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
|
||||
use crate::perception::interpretation::ObservationTrigger;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
/// Minimum ticks between monologue lines (prevents spam).
|
||||
/// At 10 ticks/game-minute, 300 ticks = 30 game-minutes.
|
||||
@@ -512,19 +514,25 @@ pub fn trigger_event_monologue(
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
registry: Option<Res<EntityRegistry>>,
|
||||
mut engagement_query: Query<&mut EngagementRecord>,
|
||||
) {
|
||||
// Drain post_conversation queue unconditionally — consumed this tick.
|
||||
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
|
||||
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
|
||||
|
||||
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else {
|
||||
// Drain post_conversation queue even without a player
|
||||
post_conv_queue.drain();
|
||||
return;
|
||||
};
|
||||
|
||||
// Don't override existing monologue from higher-priority systems
|
||||
if buffer.event.is_some() {
|
||||
post_conv_queue.drain();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save previous observation tick before update — needed for NPC attribution (#570)
|
||||
let previous_observation_tick = state.last_observation_tick;
|
||||
|
||||
// Determine which trigger to fire (priority order)
|
||||
let trigger = if observation_queue
|
||||
.as_ref()
|
||||
@@ -540,15 +548,12 @@ pub fn trigger_event_monologue(
|
||||
Some("hear_sound")
|
||||
} else if conv_buffer_opt.map(|b| !b.events.is_empty()).unwrap_or(false) {
|
||||
Some("witness_interaction")
|
||||
} else if !post_conv_queue.is_empty() {
|
||||
} else if !post_conv_npcs.is_empty() {
|
||||
Some("post_conversation")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Always drain post_conversation queue (consumed this tick)
|
||||
post_conv_queue.drain();
|
||||
|
||||
// Update observation tracking regardless of whether we fire
|
||||
if let Some(ref obs_queue) = observation_queue {
|
||||
if !obs_queue.is_empty() {
|
||||
@@ -588,6 +593,36 @@ pub fn trigger_event_monologue(
|
||||
id,
|
||||
time.tick
|
||||
);
|
||||
|
||||
// Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs.
|
||||
// Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific.
|
||||
match trigger {
|
||||
"observe_npc" => {
|
||||
// Attribute to all NPCs whose NewEntity event triggered this monologue
|
||||
if let (Some(ref obs_q), Some(ref reg)) = (&observation_queue, ®istry) {
|
||||
for event in obs_q.iter() {
|
||||
if event.tick > previous_observation_tick {
|
||||
if let ObservationTrigger::NewEntity { entity: sid, .. } = &event.trigger {
|
||||
if let Some(npc_entity) = reg.to_entity(sid) {
|
||||
if let Ok(mut record) = engagement_query.get_mut(npc_entity) {
|
||||
record.monologue_trigger_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"post_conversation" => {
|
||||
// Attribute to the NPC(s) whose conversation just ended
|
||||
for npc in &post_conv_npcs {
|
||||
if let Ok(mut record) = engagement_query.get_mut(*npc) {
|
||||
record.monologue_trigger_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {} // hear_sound, witness_interaction: no NPC-specific attribution
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any NewEntity observation events exist that we haven't processed.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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)
|
||||
// Extended by #576: TileKind layer per tile (server-authoritative tile classification)
|
||||
// Chunk-based storage per D-012: supports chunk load/unload for future borderless generation
|
||||
// Y-down convention: North = y-1, South = y+1
|
||||
|
||||
@@ -16,6 +17,30 @@ use crate::simulation::stance::Stance;
|
||||
/// Chunk size in tiles (32x32 per chunk)
|
||||
pub const CHUNK_SIZE: i32 = 32;
|
||||
|
||||
/// Server-side authoritative tile classification (#576, D-012).
|
||||
///
|
||||
/// Mirrors the bridge `TileKind` (Floor/Wall/Door/Object used for client rendering)
|
||||
/// but serves a different purpose: simulation logic and tile authoring.
|
||||
///
|
||||
/// Tile format for location YAML authoring (#577):
|
||||
/// - `F` = Floor (walkable, open space)
|
||||
/// - `W` = Wall (solid obstacle, blocks movement and LOS)
|
||||
/// - `V` = Void (out-of-bounds / unloaded; treated as blocked)
|
||||
/// - `R` = Restricted (blocked but traversable by specific entities, e.g. airlocks)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TileKind {
|
||||
/// Walkable floor tile. Default for populated chunks.
|
||||
#[default]
|
||||
Floor,
|
||||
/// Solid wall tile. Blocks movement and line-of-sight.
|
||||
Wall,
|
||||
/// Void / unloaded tile. Out-of-bounds or ungenerated space.
|
||||
Void,
|
||||
/// Restricted tile. Blocked for standard movement but accessible
|
||||
/// to authorised entities (e.g. locked zones, maintenance airlocks).
|
||||
Restricted,
|
||||
}
|
||||
|
||||
/// Marker component identifying the player-controlled entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct PlayerCharacter;
|
||||
@@ -121,7 +146,7 @@ impl TilePosition {
|
||||
}
|
||||
|
||||
/// Get the chunk coordinate this tile belongs to.
|
||||
fn chunk_coord(&self) -> ChunkCoord {
|
||||
pub fn chunk_coord(&self) -> ChunkCoord {
|
||||
ChunkCoord {
|
||||
cx: self.x.div_euclid(CHUNK_SIZE),
|
||||
cy: self.y.div_euclid(CHUNK_SIZE),
|
||||
@@ -143,22 +168,43 @@ pub struct ChunkCoord {
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
|
||||
/// Per-tile storage cell: walkability flag and tile classification.
|
||||
/// Private — exposed only through WalkabilityMap's public API.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TileCell {
|
||||
walkable: bool,
|
||||
kind: TileKind,
|
||||
}
|
||||
|
||||
/// Walkability and tile-kind data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles).
|
||||
/// Extended by #576 to carry TileKind alongside the walkability bool.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChunkData {
|
||||
tiles: Vec<bool>, // CHUNK_SIZE * CHUNK_SIZE, true = walkable
|
||||
tiles: Vec<TileCell>,
|
||||
}
|
||||
|
||||
impl ChunkData {
|
||||
fn new_walkable() -> Self {
|
||||
Self {
|
||||
tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||
tiles: vec![
|
||||
TileCell {
|
||||
walkable: true,
|
||||
kind: TileKind::Floor
|
||||
};
|
||||
(CHUNK_SIZE * CHUNK_SIZE) as usize
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn new_blocked() -> Self {
|
||||
Self {
|
||||
tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||
tiles: vec![
|
||||
TileCell {
|
||||
walkable: false,
|
||||
kind: TileKind::Wall
|
||||
};
|
||||
(CHUNK_SIZE * CHUNK_SIZE) as usize
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,12 +212,24 @@ impl ChunkData {
|
||||
(ly * CHUNK_SIZE + lx) as usize
|
||||
}
|
||||
|
||||
/// Returns the walkability flag for a tile (backward-compatible internal accessor).
|
||||
fn get(&self, lx: i32, ly: i32) -> bool {
|
||||
self.tiles[Self::index(lx, ly)]
|
||||
self.tiles[Self::index(lx, ly)].walkable
|
||||
}
|
||||
|
||||
/// Sets only the walkability flag; tile kind is unchanged.
|
||||
fn set(&mut self, lx: i32, ly: i32, walkable: bool) {
|
||||
self.tiles[Self::index(lx, ly)] = walkable;
|
||||
self.tiles[Self::index(lx, ly)].walkable = walkable;
|
||||
}
|
||||
|
||||
/// Returns the tile kind for a cell.
|
||||
fn get_kind(&self, lx: i32, ly: i32) -> TileKind {
|
||||
self.tiles[Self::index(lx, ly)].kind
|
||||
}
|
||||
|
||||
/// Sets the tile kind for a cell; walkability is unchanged.
|
||||
fn set_kind(&mut self, lx: i32, ly: i32, kind: TileKind) {
|
||||
self.tiles[Self::index(lx, ly)].kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +284,7 @@ impl WalkabilityMap {
|
||||
}
|
||||
|
||||
/// Set walkability of a tile. Creates the chunk if it doesn't exist.
|
||||
/// Does not change the tile's TileKind.
|
||||
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) {
|
||||
let coord = pos.chunk_coord();
|
||||
let (lx, ly) = pos.local_offset();
|
||||
@@ -236,6 +295,27 @@ impl WalkabilityMap {
|
||||
chunk.set(lx, ly, walkable);
|
||||
}
|
||||
|
||||
/// Get the tile kind at a position. Returns `TileKind::Void` for unloaded chunks.
|
||||
pub fn tile_kind(&self, pos: &TilePosition) -> TileKind {
|
||||
let coord = pos.chunk_coord();
|
||||
let (lx, ly) = pos.local_offset();
|
||||
self.chunks
|
||||
.get(&coord)
|
||||
.map_or(TileKind::Void, |chunk| chunk.get_kind(lx, ly))
|
||||
}
|
||||
|
||||
/// Set the tile kind at a position. Creates the chunk if it doesn't exist.
|
||||
/// Does not change the tile's walkability.
|
||||
pub fn set_tile_kind(&mut self, pos: &TilePosition, kind: TileKind) {
|
||||
let coord = pos.chunk_coord();
|
||||
let (lx, ly) = pos.local_offset();
|
||||
let chunk = self
|
||||
.chunks
|
||||
.entry(coord)
|
||||
.or_insert_with(ChunkData::new_blocked);
|
||||
chunk.set_kind(lx, ly, kind);
|
||||
}
|
||||
|
||||
/// Check if a chunk is loaded.
|
||||
pub fn has_chunk(&self, coord: &ChunkCoord) -> bool {
|
||||
self.chunks.contains_key(coord)
|
||||
@@ -259,6 +339,12 @@ impl WalkabilityMap {
|
||||
pub fn chunk_count(&self) -> usize {
|
||||
self.chunks.len()
|
||||
}
|
||||
|
||||
/// Returns the coordinates of all loaded chunks (#578).
|
||||
/// Used by the chunk streaming system to determine which chunks to unload.
|
||||
pub fn loaded_chunk_coords(&self) -> Vec<ChunkCoord> {
|
||||
self.chunks.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Component representing an intent to move to a target tile.
|
||||
@@ -366,6 +452,89 @@ pub fn validate_movement(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TileKind layer tests (#576)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tile_kind_default_is_floor_for_walkable_chunk() {
|
||||
let map = WalkabilityMap::new(10, 10, 1);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(5, 5, 0)), TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(9, 9, 0)), TileKind::Floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_unloaded_chunk_returns_void() {
|
||||
let map = WalkabilityMap::new(10, 10, 1);
|
||||
// Negative coords → unloaded chunk → Void
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(-1, 0, 0)), TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, -1, 0)), TileKind::Void);
|
||||
// z-level not loaded → Void
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 1)), TileKind::Void);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_round_trip() {
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
let pos = TilePosition::new(5, 5, 0);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Restricted);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Restricted);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Void);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Void);
|
||||
|
||||
map.set_tile_kind(&pos, TileKind::Floor);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_independent_of_walkability() {
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
let pos = TilePosition::new(3, 3, 0);
|
||||
|
||||
// Start: Floor + walkable
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
|
||||
assert!(map.can_move_to(&pos));
|
||||
|
||||
// Set walkable = false; kind should remain Floor
|
||||
map.set_walkable(&pos, false);
|
||||
assert!(!map.can_move_to(&pos));
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Floor);
|
||||
|
||||
// Set kind = Wall; walkability should remain false
|
||||
map.set_tile_kind(&pos, TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
|
||||
assert!(!map.can_move_to(&pos));
|
||||
|
||||
// Restore walkable = true; kind should stay Wall
|
||||
map.set_walkable(&pos, true);
|
||||
assert!(map.can_move_to(&pos));
|
||||
assert_eq!(map.tile_kind(&pos), TileKind::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_kind_set_creates_chunk_on_demand() {
|
||||
let mut map = WalkabilityMap::new(1, 1, 1);
|
||||
let new_chunk_pos = TilePosition::new(32, 0, 0); // new chunk
|
||||
|
||||
assert!(!map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
|
||||
map.set_tile_kind(&new_chunk_pos, TileKind::Restricted);
|
||||
assert!(map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 }));
|
||||
assert_eq!(map.tile_kind(&new_chunk_pos), TileKind::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_chunk_default_kind_is_wall() {
|
||||
let map = WalkabilityMap::new_blocked(32, 32, 1);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Wall);
|
||||
assert_eq!(map.tile_kind(&TilePosition::new(15, 15, 0)), TileKind::Wall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_position_equality() {
|
||||
let pos1 = TilePosition::new(5, 10, 0);
|
||||
|
||||
@@ -31,7 +31,10 @@ use crate::simulation::interaction::DoorState;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::content::template::TriangleCrisisEventQueue;
|
||||
use crate::storyteller::{ContaminationActive, ContaminationEventQueue};
|
||||
use crate::storyteller::{
|
||||
ActivationState, ContaminationActive, ContaminationEventQueue, MovementHistoryBuffer,
|
||||
TriangleActivatedQueue,
|
||||
};
|
||||
|
||||
/// Errors from save/load operations (#553).
|
||||
#[derive(Debug, Error)]
|
||||
@@ -149,6 +152,12 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError>
|
||||
contamination_active: world
|
||||
.get_resource::<ContaminationActive>()
|
||||
.map_or(false, |c| c.0),
|
||||
activated_count: world
|
||||
.get_resource::<ActivationState>()
|
||||
.map_or(0, |a| a.activated_count),
|
||||
last_activation_tick: world
|
||||
.get_resource::<ActivationState>()
|
||||
.and_then(|a| a.last_activation_tick),
|
||||
};
|
||||
|
||||
let bytes = state
|
||||
@@ -268,10 +277,18 @@ pub fn load_from_file(path: &Path, world: &mut World) -> Result<(), SaveLoadErro
|
||||
// Restore contamination state (#254) — prevents double-firing on reload.
|
||||
world.insert_resource(ContaminationActive(state.contamination_active));
|
||||
|
||||
// Reset event queues — prevent stale events from the pre-load world
|
||||
// leaking into the post-load simulation.
|
||||
// Restore activation state (#572) — prevents double-activation on reload.
|
||||
world.insert_resource(ActivationState {
|
||||
activated_count: state.activated_count,
|
||||
last_activation_tick: state.last_activation_tick,
|
||||
});
|
||||
|
||||
// Reset event queues and transient buffers — prevent stale events/history
|
||||
// from the pre-load world leaking into the post-load simulation.
|
||||
world.insert_resource(ContaminationEventQueue::default());
|
||||
world.insert_resource(TriangleCrisisEventQueue::default());
|
||||
world.insert_resource(TriangleActivatedQueue::default());
|
||||
world.insert_resource(MovementHistoryBuffer::default());
|
||||
|
||||
// Restore door open states (#246) — find door entities by StableId and toggle.
|
||||
if !state.open_doors.is_empty() {
|
||||
@@ -401,6 +418,7 @@ mod tests {
|
||||
w.insert_resource(RelationshipGraph::new());
|
||||
w.init_resource::<EntityRegistry>();
|
||||
w.init_resource::<ContaminationActive>();
|
||||
w.init_resource::<ActivationState>();
|
||||
w
|
||||
}
|
||||
|
||||
@@ -591,6 +609,8 @@ mod tests {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
};
|
||||
let bytes = bad_state.to_bytes().expect("serialize");
|
||||
let path = temp_path();
|
||||
@@ -862,4 +882,65 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ActivationState roundtrip (#572, Task #12)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_from_file_restores_activation_state() {
|
||||
let mut world = minimal_world();
|
||||
|
||||
// Set activation state: 1 activation at tick 500
|
||||
world.insert_resource(ActivationState {
|
||||
activated_count: 1,
|
||||
last_activation_tick: Some(500),
|
||||
});
|
||||
|
||||
let path = temp_path();
|
||||
save_to_file(&path, &mut world).expect("save");
|
||||
|
||||
// Reset activation state to defaults before load
|
||||
world.insert_resource(ActivationState::default());
|
||||
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
|
||||
assert!(world.resource::<ActivationState>().last_activation_tick.is_none());
|
||||
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let state = world.resource::<ActivationState>();
|
||||
assert_eq!(
|
||||
state.activated_count, 1,
|
||||
"activated_count must survive save/load"
|
||||
);
|
||||
assert_eq!(
|
||||
state.last_activation_tick,
|
||||
Some(500),
|
||||
"last_activation_tick must survive save/load"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_file_restores_zero_activation_state() {
|
||||
// Verify that saves with no activations restore correctly (serde(default))
|
||||
let mut world = minimal_world();
|
||||
|
||||
let path = temp_path();
|
||||
save_to_file(&path, &mut world).expect("save");
|
||||
|
||||
// Pollute state before load
|
||||
world.insert_resource(ActivationState {
|
||||
activated_count: 5,
|
||||
last_activation_tick: Some(9999),
|
||||
});
|
||||
|
||||
load_from_file(&path, &mut world).expect("load");
|
||||
|
||||
let state = world.resource::<ActivationState>();
|
||||
assert_eq!(state.activated_count, 0);
|
||||
assert!(state.last_activation_tick.is_none());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::npc::relationships::RelationshipGraph;
|
||||
use crate::npc::vision::{NpcMemory, NpcVisionState};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::time::TickRate;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
/// Current format version. Bump on any breaking schema change.
|
||||
pub const SAVE_FORMAT_VERSION: u8 = 1;
|
||||
@@ -111,6 +112,16 @@ pub struct SaveStateV1 {
|
||||
/// and apply a duplicate tension delta to all ActiveFork triangles.
|
||||
#[serde(default)]
|
||||
pub contamination_active: bool,
|
||||
/// Number of triangles activated this session (#572).
|
||||
/// Persisted to prevent double-activation on save/load — without this,
|
||||
/// reloading a save after activation would reset the one-shot guard
|
||||
/// and allow a second triangle to be activated.
|
||||
#[serde(default)]
|
||||
pub activated_count: u32,
|
||||
/// Tick at which the most recent triangle activation occurred (#572).
|
||||
/// `None` if no activation yet. Persisted alongside `activated_count`.
|
||||
#[serde(default)]
|
||||
pub last_activation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||
@@ -363,6 +374,7 @@ pub fn deserialize_npc_from_frozen(state: &NpcSaveState, world: &mut World) -> E
|
||||
NpcVisionState::default(),
|
||||
NpcMemory::default(),
|
||||
PlayerAwareness::default(),
|
||||
EngagementRecord::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
@@ -425,6 +437,8 @@ mod tests {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +838,8 @@ mod tests {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
};
|
||||
|
||||
let bytes = save.to_bytes().expect("serialize");
|
||||
|
||||
@@ -12,11 +12,54 @@
|
||||
//! 1. Sets `ContaminationActive` resource to true (one-shot)
|
||||
//! 2. Applies a tension delta to all `ActiveFork` triangles
|
||||
//! 3. Emits a `ContaminationEvent` for downstream systems (monologue, etc.)
|
||||
//!
|
||||
//! ## Activation Lifecycle (#572)
|
||||
//!
|
||||
//! After contamination, the activation pass (#162, #579) runs on a 10-tick
|
||||
//! cadence, scoring NPCs by engagement and activating the best-fit triangle.
|
||||
//!
|
||||
//! **v0.1 lifecycle rules (single-activation model):**
|
||||
//!
|
||||
//! 1. **One activation per session.** The vertical slice is a ~30-minute
|
||||
//! experience. Once a triangle is activated, the storyteller does not
|
||||
//! activate additional triangles. `ActivationState::Activated` is the
|
||||
//! terminal state for the storyteller within a single playthrough.
|
||||
//!
|
||||
//! 2. **No concurrent activations.** At most one triangle can be in the
|
||||
//! `TrianglePhase::Active` state at any time. This is trivially enforced
|
||||
//! by rule 1 for v0.1 — future versions (v0.3+) will need a concurrency
|
||||
//! limit and priority queue.
|
||||
//!
|
||||
//! 3. **No cooldown.** Since only one activation fires, there is no cooldown
|
||||
//! period between activations. Future multi-activation will need
|
||||
//! `ACTIVATION_COOLDOWN_TICKS` — stub the constant now at 0.
|
||||
//!
|
||||
//! 4. **Resolution is terminal.** When a triangle reaches `TrianglePhase::Resolved`
|
||||
//! (player completes investigation or consequences fire), it stays resolved.
|
||||
//! The storyteller does not re-activate resolved triangles. For v0.1 this
|
||||
//! is moot (one-shot), but the activation pass must still check for it.
|
||||
//!
|
||||
//! 5. **Activation cadence.** The activation pass polls every
|
||||
//! `ACTIVATION_CADENCE_TICKS` (10 ticks = 1 game-second). This is cheap
|
||||
//! because the pass no-ops if `ActivationState` is already `Activated`.
|
||||
//!
|
||||
//! **Future extensions (v0.3+):**
|
||||
//! - `MAX_CONCURRENT_ACTIVATIONS` > 1
|
||||
//! - `ACTIVATION_COOLDOWN_TICKS` > 0 between successive activations
|
||||
//! - `SelectionStrategy::WeightedRandom` alongside `HighestScore`
|
||||
//! - Hubris wall gating (activation blocked above a threshold)
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::content::template::{TriangleClassification, TriangleState};
|
||||
use crate::content::template::{TriangleClassification, TriangleId, TrianglePhase, TriangleState};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
@@ -34,10 +77,68 @@ pub const CONTAMINATION_PRESSURE_DELTA: u8 = 10;
|
||||
/// 0–100 scale. Not yet consumed — placeholder for future confrontation system.
|
||||
pub const CONFRONTATION_THRESHOLD: u8 = 75;
|
||||
|
||||
// --- Activation lifecycle constants (#572) ----------------------------------
|
||||
|
||||
/// How often the activation pass runs, in ticks.
|
||||
/// 10 ticks = 1 game-second (D-031). Cheap — no-ops if already activated.
|
||||
pub const ACTIVATION_CADENCE_TICKS: u64 = 10;
|
||||
|
||||
/// Maximum concurrent triangle activations. v0.1 = 1 (single-activation).
|
||||
pub const MAX_CONCURRENT_ACTIVATIONS: u32 = 1;
|
||||
|
||||
/// Cooldown between successive activations, in ticks. v0.1 = 0 (one-shot).
|
||||
/// Future multi-activation (v0.3+) will set this to e.g. 600 (1 game-minute).
|
||||
pub const ACTIVATION_COOLDOWN_TICKS: u64 = 0;
|
||||
|
||||
/// Engagement window for NPC co-presence scoring (#162 step 2).
|
||||
/// 3000 ticks = 5 game-minutes = ~5 real minutes at 10 tps.
|
||||
pub const ENGAGEMENT_WINDOW_TICKS: u64 = 3000;
|
||||
|
||||
// --- Engagement scoring weights (#162 step 3) -------------------------------
|
||||
|
||||
/// Weight per conversation (capped at `CONVERSATION_CAP`).
|
||||
pub const ENGAGEMENT_WEIGHT_CONVERSATION: f32 = 2.0;
|
||||
|
||||
/// Maximum conversations counted toward engagement score.
|
||||
pub const CONVERSATION_CAP: u32 = 5;
|
||||
|
||||
/// Weight per tick of observation time.
|
||||
/// 200 ticks (~20 real seconds) = 1 point.
|
||||
pub const ENGAGEMENT_WEIGHT_OBSERVATION: f32 = 0.005;
|
||||
|
||||
/// Weight per monologue trigger — richest signal.
|
||||
pub const ENGAGEMENT_WEIGHT_MONOLOGUE: f32 = 8.0;
|
||||
|
||||
/// Proximity threshold (Chebyshev tiles) for co-presence detection in activation_pass.
|
||||
/// Matches NaturalVision forward_range (D-015, D-017): 20 tiles.
|
||||
pub const COPRESENCE_THRESHOLD: i32 = 20;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-NPC engagement metrics for storyteller activation scoring (#570, #162).
|
||||
///
|
||||
/// Tracks cumulative engagement signals the player has generated with a specific NPC.
|
||||
/// Used by `activation_pass()` (#579) to score NPCs and select the best-fit triangle.
|
||||
///
|
||||
/// All three fields are additive counters — never overwritten, only incremented.
|
||||
///
|
||||
/// **Write sites:**
|
||||
/// - `observation_time_ticks`: `perception::observation::emit_observation_events` — +1 per tick in LOS
|
||||
/// - `conversation_count`: `simulation::dialogue::process_talk_interaction` — +1 on Talk start
|
||||
/// - `monologue_trigger_count`:`simulation::monologue::trigger_event_monologue` — +1 on NPC-context fire
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
pub struct EngagementRecord {
|
||||
/// Cumulative ticks this NPC was in the player's direct LOS.
|
||||
pub observation_time_ticks: u64,
|
||||
/// Number of player-initiated conversation starts with this NPC.
|
||||
pub conversation_count: u32,
|
||||
/// Number of monologue triggers fired in this NPC's context
|
||||
/// (observe_npc or post_conversation referencing this entity).
|
||||
pub monologue_trigger_count: u32,
|
||||
}
|
||||
|
||||
/// Whether contamination has been activated by the storyteller.
|
||||
///
|
||||
/// Once set to `true`, it stays true for the remainder of the session.
|
||||
@@ -45,6 +146,56 @@ pub const CONFRONTATION_THRESHOLD: u8 = 75;
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct ContaminationActive(pub bool);
|
||||
|
||||
/// Storyteller activation state (#572).
|
||||
///
|
||||
/// Tracks how many triangles have been activated this session. For v0.1 this
|
||||
/// is a simple boolean gate — once `activated_count >= MAX_CONCURRENT_ACTIVATIONS`,
|
||||
/// the activation pass no-ops. Persisted in `SaveStateV1`.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct ActivationState {
|
||||
/// Number of triangles activated this session.
|
||||
pub activated_count: u32,
|
||||
/// Tick at which the most recent activation occurred. `None` if no activation yet.
|
||||
pub last_activation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ActivationState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActivationState {
|
||||
/// Whether the activation pass should attempt to activate a new triangle.
|
||||
///
|
||||
/// For v0.1 this returns `false` after the first activation. Future versions
|
||||
/// will also check cooldown elapsed since `last_activation_tick`.
|
||||
pub fn can_activate(&self, _current_tick: u64) -> bool {
|
||||
if self.activated_count >= MAX_CONCURRENT_ACTIVATIONS {
|
||||
return false;
|
||||
}
|
||||
// v0.1: cooldown is 0, so no cooldown check needed.
|
||||
// Future: check (current_tick - last_activation_tick) >= ACTIVATION_COOLDOWN_TICKS
|
||||
if ACTIVATION_COOLDOWN_TICKS > 0 {
|
||||
if let Some(last) = self.last_activation_tick {
|
||||
if _current_tick.saturating_sub(last) < ACTIVATION_COOLDOWN_TICKS {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Record that a triangle was activated at the given tick.
|
||||
pub fn record_activation(&mut self, tick: u64) {
|
||||
self.activated_count += 1;
|
||||
self.last_activation_tick = Some(tick);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -84,6 +235,129 @@ impl ContaminationEventQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emitted when the activation pass selects and activates a triangle (#162, #572).
|
||||
///
|
||||
/// Downstream consumers:
|
||||
/// - Tell system (D-024 axis 9): escalate tell behavior frequency
|
||||
/// - Routine scheduler: activated triangle NPCs may deviate from routine
|
||||
/// - Monologue system (D-016): unlock `triangle_activated` context lines
|
||||
/// - FRIEND arc manager (D-034): advance relationship phase if applicable
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TriangleActivatedEvent {
|
||||
/// Which triangle was activated.
|
||||
pub triangle_id: TriangleId,
|
||||
/// Tick at which activation occurred.
|
||||
pub tick: u64,
|
||||
/// The NPC entity that scored highest (activation anchor).
|
||||
pub anchor_entity: Entity,
|
||||
/// Engagement score of the anchor NPC.
|
||||
pub anchor_score: f32,
|
||||
}
|
||||
|
||||
/// Resource queue for triangle activation events.
|
||||
///
|
||||
/// Same drain pattern as `ContaminationEventQueue`. Populated by
|
||||
/// `activation_pass()` (#579), consumed by downstream systems.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct TriangleActivatedQueue {
|
||||
pub events: Vec<TriangleActivatedEvent>,
|
||||
}
|
||||
|
||||
impl TriangleActivatedQueue {
|
||||
pub fn push(&mut self, event: TriangleActivatedEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<TriangleActivatedEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Movement history (#571)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ring buffer of recent player tile positions for storyteller proximity scoring.
|
||||
///
|
||||
/// Retains the player's path over the last `ENGAGEMENT_WINDOW_TICKS` ticks.
|
||||
/// Provides `npcs_copresent_in_window` to identify which NPCs have shared
|
||||
/// space with the player recently, feeding the activation pass (#162, #579).
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct MovementHistoryBuffer {
|
||||
positions: VecDeque<TilePosition>,
|
||||
}
|
||||
|
||||
impl Default for MovementHistoryBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
positions: VecDeque::with_capacity(ENGAGEMENT_WINDOW_TICKS as usize),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MovementHistoryBuffer {
|
||||
/// Append the player's current position for this tick.
|
||||
///
|
||||
/// Maintains a maximum of `ENGAGEMENT_WINDOW_TICKS` entries by evicting
|
||||
/// the oldest position when the buffer is full.
|
||||
pub fn append(&mut self, pos: TilePosition) {
|
||||
if self.positions.len() >= ENGAGEMENT_WINDOW_TICKS as usize {
|
||||
self.positions.pop_front();
|
||||
}
|
||||
self.positions.push_back(pos);
|
||||
}
|
||||
|
||||
/// Returns entities from `npc_positions` whose position is within
|
||||
/// `threshold` tiles (Chebyshev distance) of any recorded player position
|
||||
/// in the buffer on the same z-level.
|
||||
///
|
||||
/// Used by the activation pass (#579) to identify which NPCs the player
|
||||
/// was co-present with during the engagement window.
|
||||
pub fn npcs_copresent_in_window(
|
||||
&self,
|
||||
npc_positions: impl Iterator<Item = (Entity, TilePosition)>,
|
||||
threshold: i32,
|
||||
) -> Vec<Entity> {
|
||||
npc_positions
|
||||
.filter(|(_, npc_pos)| {
|
||||
self.positions.iter().any(|player_pos| {
|
||||
player_pos.z == npc_pos.z
|
||||
&& (player_pos.x - npc_pos.x).abs() <= threshold
|
||||
&& (player_pos.y - npc_pos.y).abs() <= threshold
|
||||
})
|
||||
})
|
||||
.map(|(entity, _)| entity)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of recorded positions in the buffer.
|
||||
pub fn len(&self) -> usize {
|
||||
self.positions.len()
|
||||
}
|
||||
|
||||
/// True if the buffer has no recorded positions.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.positions.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// System: append the player's current position to the movement history buffer.
|
||||
///
|
||||
/// Runs each tick after `validate_movement`. Maintains the sliding
|
||||
/// `ENGAGEMENT_WINDOW_TICKS` window consumed by the activation pass (#162).
|
||||
pub fn append_player_history(
|
||||
mut history: ResMut<MovementHistoryBuffer>,
|
||||
player_query: Query<&TilePosition, With<PlayerCharacter>>,
|
||||
) {
|
||||
if let Ok(pos) = player_query.single() {
|
||||
history.append(*pos);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -95,7 +369,22 @@ impl Plugin for StorytellerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ContaminationActive>()
|
||||
.init_resource::<ContaminationEventQueue>()
|
||||
.add_systems(Update, tick_contamination_activation);
|
||||
.init_resource::<ActivationState>()
|
||||
.init_resource::<TriangleActivatedQueue>()
|
||||
.init_resource::<MovementHistoryBuffer>()
|
||||
.add_systems(Update, tick_contamination_activation)
|
||||
.add_systems(
|
||||
Update,
|
||||
append_player_history
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
activation_pass
|
||||
.after(append_player_history)
|
||||
.after(tick_contamination_activation)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
);
|
||||
|
||||
tracing::debug!("StorytellerPlugin initialized");
|
||||
}
|
||||
@@ -153,6 +442,166 @@ pub fn tick_contamination_activation(
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: compute engagement score from an EngagementRecord.
|
||||
///
|
||||
/// Formula: capped conversations × weight + observation ticks × weight + monologue triggers × weight.
|
||||
/// Implements #162 step 3 scoring.
|
||||
fn compute_engagement_score(record: &EngagementRecord) -> f32 {
|
||||
let conv_score = record.conversation_count.min(CONVERSATION_CAP) as f32
|
||||
* ENGAGEMENT_WEIGHT_CONVERSATION;
|
||||
// Use f64 intermediate to avoid precision loss beyond ~16.8M ticks (2^24),
|
||||
// where f32 can no longer distinguish adjacent integers.
|
||||
let obs_score = (record.observation_time_ticks as f64 * ENGAGEMENT_WEIGHT_OBSERVATION as f64) as f32;
|
||||
let mono_score = record.monologue_trigger_count as f32 * ENGAGEMENT_WEIGHT_MONOLOGUE;
|
||||
conv_score + obs_score + mono_score
|
||||
}
|
||||
|
||||
/// System: storyteller activation pass — select and activate the best-fit triangle.
|
||||
///
|
||||
/// Implements #162 steps 1–6. Runs on a 10-tick cadence after contamination fires.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Gate: contamination active + activation limit not reached + cadence check.
|
||||
/// 2. Co-presence query: find NPCs near any recorded player position (MovementHistoryBuffer).
|
||||
/// 3. Engagement scoring: score each co-present NPC via EngagementRecord.
|
||||
/// 4+5. Triangle routing: find Simmering triangle containing highest-scoring co-present NPC.
|
||||
/// v0.1: NPCs not assigned to any triangle are excluded (D-025 reference link routing deferred to v0.3+).
|
||||
/// Holds (no activation) if no triangle-assigned NPC is co-present.
|
||||
/// 6. Activation event: emit TriangleActivatedEvent, record in ActivationState.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn activation_pass(
|
||||
time: Res<SimulationTime>,
|
||||
contamination: Res<ContaminationActive>,
|
||||
mut activation_state: ResMut<ActivationState>,
|
||||
history: Res<MovementHistoryBuffer>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut triangles: Query<(Entity, &mut TriangleState), With<ActiveSim>>,
|
||||
npcs: Query<(Entity, &TilePosition, Option<&EngagementRecord>), (With<Npc>, With<ActiveSim>)>,
|
||||
mut event_queue: ResMut<TriangleActivatedQueue>,
|
||||
) {
|
||||
// Gate 1: contamination must be active
|
||||
if !contamination.0 {
|
||||
return;
|
||||
}
|
||||
// Gate 2: activation limit
|
||||
if !activation_state.can_activate(time.tick) {
|
||||
return;
|
||||
}
|
||||
// Gate 3: poll cadence — only run every ACTIVATION_CADENCE_TICKS.
|
||||
// Guard: at tick 0 all engagement scores are 0.0 and selection is random
|
||||
// (e.g. contamination pre-set in a save file). Skip tick 0.
|
||||
if time.tick == 0 || time.tick % ACTIVATION_CADENCE_TICKS != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: co-presence query
|
||||
let npc_positions: Vec<(Entity, TilePosition)> =
|
||||
npcs.iter().map(|(e, pos, _)| (e, *pos)).collect();
|
||||
let mut copresent = history.npcs_copresent_in_window(npc_positions.into_iter(), COPRESENCE_THRESHOLD);
|
||||
// Deduplicate: the co-presence query can return the same entity more than once
|
||||
// if multiple player positions fall near the same NPC. Without dedup, the NPC
|
||||
// gets added to candidates twice, doubling its RNG weight.
|
||||
copresent.sort_unstable();
|
||||
copresent.dedup();
|
||||
|
||||
// Snapshot simmering triangles for read (avoids double-borrow during activation write)
|
||||
let simmering: Vec<(Entity, TriangleState)> = triangles
|
||||
.iter()
|
||||
.filter(|(_, s)| s.phase == TrianglePhase::Simmering)
|
||||
.map(|(e, s)| (e, s.clone()))
|
||||
.collect();
|
||||
|
||||
if simmering.is_empty() {
|
||||
tracing::warn!("activation_pass: no Simmering triangles — holding");
|
||||
return;
|
||||
}
|
||||
|
||||
// Steps 3+4: score co-present NPCs that belong to a Simmering triangle.
|
||||
// Unentangled NPCs (not in any triangle) are excluded; v0.1 skips D-025 routing.
|
||||
let mut candidates: Vec<(Entity, Entity, f32)> = Vec::new(); // (npc_entity, tri_entity, score)
|
||||
for &npc_entity in &copresent {
|
||||
let Some(stable_id) = registry.to_stable(npc_entity) else {
|
||||
continue;
|
||||
};
|
||||
let score = npcs
|
||||
.get(npc_entity)
|
||||
.ok()
|
||||
.and_then(|(_, _, r)| r)
|
||||
.map(compute_engagement_score)
|
||||
.unwrap_or(0.0);
|
||||
for (tri_entity, tri_state) in &simmering {
|
||||
if tri_state
|
||||
.role_assignments
|
||||
.values()
|
||||
.any(|sid| *sid == stable_id)
|
||||
{
|
||||
candidates.push((npc_entity, *tri_entity, score));
|
||||
break; // each NPC is assigned to at most one triangle per pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
tracing::warn!(
|
||||
"activation_pass: no co-present NPC is assigned to a Simmering triangle at tick {} — holding",
|
||||
time.tick
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: select best candidate; SimRng tie-break among equal top scores (D-010)
|
||||
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let top_score = candidates[0].2;
|
||||
let top_count = candidates
|
||||
.iter()
|
||||
.take_while(|(_, _, s)| (*s - top_score).abs() <= f32::EPSILON * top_score.abs().max(1.0))
|
||||
.count();
|
||||
let selected_idx = if top_count > 1 {
|
||||
rng.rng.random_range(0..top_count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (anchor_entity, tri_entity, anchor_score) = candidates[selected_idx];
|
||||
|
||||
// Retrieve triangle_id for the event.
|
||||
// Guard: the entity was in the simmering snapshot we collected above, but in
|
||||
// theory it could have been despawned between snapshot and lookup (unlikely but
|
||||
// a panic in the gameplay loop is unacceptable).
|
||||
let Some(triangle_id) = simmering
|
||||
.iter()
|
||||
.find(|(e, _)| *e == tri_entity)
|
||||
.map(|(_, s)| s.triangle_id)
|
||||
else {
|
||||
tracing::error!(
|
||||
"activation_pass: target triangle entity {:?} missing from simmering snapshot — skipping activation at tick {}",
|
||||
tri_entity, time.tick
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Step 6: set triangle phase to Active
|
||||
if let Ok((_, mut tri_state)) = triangles.get_mut(tri_entity) {
|
||||
tri_state.phase = TrianglePhase::Active;
|
||||
}
|
||||
|
||||
// Step 7: record activation and emit event
|
||||
activation_state.record_activation(time.tick);
|
||||
event_queue.push(TriangleActivatedEvent {
|
||||
triangle_id,
|
||||
tick: time.tick,
|
||||
anchor_entity,
|
||||
anchor_score,
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
"activation_pass: activated triangle {} at tick {} (anchor score: {:.2})",
|
||||
triangle_id.0,
|
||||
time.tick,
|
||||
anchor_score,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,4 +760,325 @@ mod tests {
|
||||
let state = q.single(&world).unwrap();
|
||||
assert_eq!(state.tension, 255);
|
||||
}
|
||||
|
||||
// --- ActivationState lifecycle tests (#572) ---
|
||||
|
||||
#[test]
|
||||
fn activation_state_allows_first_activation() {
|
||||
let state = ActivationState::default();
|
||||
assert!(state.can_activate(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_state_blocks_after_max() {
|
||||
let mut state = ActivationState::default();
|
||||
state.record_activation(500);
|
||||
// v0.1: MAX_CONCURRENT_ACTIVATIONS = 1, so second is blocked
|
||||
assert!(!state.can_activate(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_state_records_tick() {
|
||||
let mut state = ActivationState::default();
|
||||
assert_eq!(state.activated_count, 0);
|
||||
assert!(state.last_activation_tick.is_none());
|
||||
|
||||
state.record_activation(1000);
|
||||
assert_eq!(state.activated_count, 1);
|
||||
assert_eq!(state.last_activation_tick, Some(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangle_activated_queue_drain() {
|
||||
let mut queue = TriangleActivatedQueue::default();
|
||||
assert!(queue.is_empty());
|
||||
|
||||
let mut world = World::new();
|
||||
let entity = world.spawn_empty().id();
|
||||
queue.push(TriangleActivatedEvent {
|
||||
triangle_id: TriangleId::from_seed_and_slug(0, "test"),
|
||||
tick: 500,
|
||||
anchor_entity: entity,
|
||||
anchor_score: 12.5,
|
||||
});
|
||||
assert!(!queue.is_empty());
|
||||
|
||||
let events = queue.drain();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
// --- MovementHistoryBuffer tests (#571) ---
|
||||
|
||||
#[test]
|
||||
fn movement_history_append_and_len() {
|
||||
let mut buf = MovementHistoryBuffer::default();
|
||||
assert!(buf.is_empty());
|
||||
|
||||
buf.append(TilePosition::new(1, 2, 0));
|
||||
buf.append(TilePosition::new(3, 4, 0));
|
||||
assert_eq!(buf.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_history_evicts_oldest_at_capacity() {
|
||||
let mut buf = MovementHistoryBuffer::default();
|
||||
// Fill to capacity
|
||||
for i in 0..ENGAGEMENT_WINDOW_TICKS as i32 {
|
||||
buf.append(TilePosition::new(i, 0, 0));
|
||||
}
|
||||
assert_eq!(buf.len(), ENGAGEMENT_WINDOW_TICKS as usize);
|
||||
|
||||
// One more — should evict x=0
|
||||
buf.append(TilePosition::new(9999, 0, 0));
|
||||
assert_eq!(
|
||||
buf.len(),
|
||||
ENGAGEMENT_WINDOW_TICKS as usize,
|
||||
"buffer must not grow beyond ENGAGEMENT_WINDOW_TICKS"
|
||||
);
|
||||
// The oldest entry (x=0) should be gone; newest (x=9999) should be present
|
||||
let mut world = World::new();
|
||||
let entity_old = world.spawn_empty().id();
|
||||
let entity_new = world.spawn_empty().id();
|
||||
let copresent = buf.npcs_copresent_in_window(
|
||||
[(entity_old, TilePosition::new(0, 0, 0))].into_iter(),
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
copresent.is_empty(),
|
||||
"NPC at evicted position x=0 should not be copresent"
|
||||
);
|
||||
let copresent_new = buf.npcs_copresent_in_window(
|
||||
[(entity_new, TilePosition::new(9999, 0, 0))].into_iter(),
|
||||
0,
|
||||
);
|
||||
assert_eq!(copresent_new.len(), 1, "NPC at newest position should be copresent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npcs_copresent_finds_nearby_npc() {
|
||||
let mut buf = MovementHistoryBuffer::default();
|
||||
buf.append(TilePosition::new(10, 10, 0));
|
||||
buf.append(TilePosition::new(11, 10, 0));
|
||||
|
||||
let mut world = World::new();
|
||||
let nearby = world.spawn_empty().id();
|
||||
let distant = world.spawn_empty().id();
|
||||
|
||||
// NPC 1 tile from a history position — within threshold 2
|
||||
let result = buf.npcs_copresent_in_window(
|
||||
[
|
||||
(nearby, TilePosition::new(10, 11, 0)),
|
||||
(distant, TilePosition::new(50, 50, 0)),
|
||||
]
|
||||
.into_iter(),
|
||||
2,
|
||||
);
|
||||
|
||||
assert!(result.contains(&nearby), "nearby NPC should be copresent");
|
||||
assert!(!result.contains(&distant), "distant NPC should not be copresent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npcs_copresent_different_z_not_included() {
|
||||
let mut buf = MovementHistoryBuffer::default();
|
||||
buf.append(TilePosition::new(10, 10, 0));
|
||||
|
||||
let mut world = World::new();
|
||||
let npc = world.spawn_empty().id();
|
||||
|
||||
// Same x/y but different z
|
||||
let result = buf.npcs_copresent_in_window(
|
||||
[(npc, TilePosition::new(10, 10, 1))].into_iter(),
|
||||
0,
|
||||
);
|
||||
assert!(
|
||||
result.is_empty(),
|
||||
"NPC on different z-level must not be copresent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_player_history_system_appends_position() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<MovementHistoryBuffer>();
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 7, 0)));
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(append_player_history);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buf = world.resource::<MovementHistoryBuffer>();
|
||||
assert_eq!(buf.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_player_history_system_no_player_no_panic() {
|
||||
// Should silently no-op if no player entity is present
|
||||
let mut world = World::new();
|
||||
world.init_resource::<MovementHistoryBuffer>();
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(append_player_history);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.resource::<MovementHistoryBuffer>().is_empty());
|
||||
}
|
||||
|
||||
// --- activation_pass tests (#579) ---
|
||||
|
||||
fn setup_activation_world() -> (World, Schedule) {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(ContaminationActive(true));
|
||||
world.init_resource::<ActivationState>();
|
||||
world.init_resource::<MovementHistoryBuffer>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<TriangleActivatedQueue>();
|
||||
world.insert_resource(crate::simulation::rng::SimRng::new(42));
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(activation_pass);
|
||||
(world, schedule)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pass_blocked_before_contamination() {
|
||||
let (mut world, mut schedule) = setup_activation_world();
|
||||
world.insert_resource(ContaminationActive(false));
|
||||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
|
||||
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pass_blocked_off_cadence() {
|
||||
let (mut world, mut schedule) = setup_activation_world();
|
||||
// Off-cadence tick (not divisible by ACTIVATION_CADENCE_TICKS)
|
||||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS + 3;
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pass_blocked_after_max_activations() {
|
||||
let (mut world, mut schedule) = setup_activation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||||
// Record activation up to the limit
|
||||
world.resource_mut::<ActivationState>().record_activation(0);
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Should not fire again
|
||||
assert!(world.resource::<TriangleActivatedQueue>().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pass_holds_when_no_triangle_npc_copresent() {
|
||||
// No NPCs present at all — no candidates, should hold (not activate a random triangle)
|
||||
let (mut world, mut schedule) = setup_activation_world();
|
||||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||||
|
||||
// Spawn a Simmering triangle (but no NPCs)
|
||||
world.spawn((make_triangle(TriangleClassification::ActiveFork), ActiveSim));
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Should NOT activate — no co-present NPC is assigned to any triangle
|
||||
assert!(
|
||||
world.resource::<TriangleActivatedQueue>().is_empty(),
|
||||
"activation should hold when no triangle-assigned NPC is copresent"
|
||||
);
|
||||
assert_eq!(world.resource::<ActivationState>().activated_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pass_activates_triangle_via_copresent_npc() {
|
||||
let (mut world, mut schedule) = setup_activation_world();
|
||||
let tick = ACTIVATION_CADENCE_TICKS;
|
||||
world.resource_mut::<SimulationTime>().tick = tick;
|
||||
|
||||
// Register NPC entity in EntityRegistry to get a StableId
|
||||
let npc_entity = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(5, 5, 0))).id();
|
||||
let stable_id = world.resource_mut::<EntityRegistry>().register(npc_entity);
|
||||
|
||||
// Spawn a Simmering triangle with the NPC in a role
|
||||
let mut tri = make_triangle(TriangleClassification::ActiveFork);
|
||||
tri.role_assignments.insert(RoleId::new("a"), stable_id);
|
||||
let tri_entity = world.spawn((tri, ActiveSim)).id();
|
||||
|
||||
// Put the player nearby in the history buffer
|
||||
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(5, 5, 0));
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
let events = world.resource_mut::<TriangleActivatedQueue>().drain();
|
||||
assert_eq!(events.len(), 1);
|
||||
let tri_state = world.get::<TriangleState>(tri_entity).unwrap();
|
||||
assert_eq!(tri_state.phase, TrianglePhase::Active, "triangle containing copresent NPC should be Active");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pass_does_not_fire_twice() {
|
||||
// Verify the one-shot guard: a real first activation must happen, then confirm
|
||||
// the second pass is blocked by ActivationState (not vacuously by empty candidates).
|
||||
let (mut world, mut schedule) = setup_activation_world();
|
||||
|
||||
// Register an NPC and assign it to a triangle role so candidates are non-empty
|
||||
let npc = world.spawn((crate::npc::Npc, ActiveSim, TilePosition::new(1, 1, 0))).id();
|
||||
let stable_id = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut tri = make_triangle(TriangleClassification::ActiveFork);
|
||||
tri.role_assignments.insert(RoleId::new("a"), stable_id);
|
||||
world.spawn((tri, ActiveSim));
|
||||
|
||||
// Put the player nearby so the NPC is copresent
|
||||
world.resource_mut::<MovementHistoryBuffer>().append(TilePosition::new(1, 1, 0));
|
||||
|
||||
// First run — should activate
|
||||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS;
|
||||
schedule.run(&mut world);
|
||||
let first_events = world.resource_mut::<TriangleActivatedQueue>().drain();
|
||||
assert_eq!(first_events.len(), 1, "first pass must activate the triangle");
|
||||
assert_eq!(
|
||||
world.resource::<ActivationState>().activated_count, 1,
|
||||
"ActivationState must record the first activation"
|
||||
);
|
||||
|
||||
// Second run — ActivationState blocks it (v0.1 one-shot)
|
||||
world.resource_mut::<SimulationTime>().tick = ACTIVATION_CADENCE_TICKS * 2;
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.resource::<TriangleActivatedQueue>().is_empty(),
|
||||
"activation must not fire a second time (v0.1 one-shot)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_engagement_score_formula() {
|
||||
let record = EngagementRecord {
|
||||
observation_time_ticks: 200,
|
||||
conversation_count: 3,
|
||||
monologue_trigger_count: 1,
|
||||
};
|
||||
let score = compute_engagement_score(&record);
|
||||
// 3 * 2.0 + 200 * 0.005 + 1 * 8.0 = 6.0 + 1.0 + 8.0 = 15.0
|
||||
assert!((score - 15.0_f32).abs() < 0.001, "score={score}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_engagement_score_caps_conversations() {
|
||||
let record = EngagementRecord {
|
||||
observation_time_ticks: 0,
|
||||
conversation_count: 100, // way above CONVERSATION_CAP = 5
|
||||
monologue_trigger_count: 0,
|
||||
};
|
||||
let score = compute_engagement_score(&record);
|
||||
// capped at 5 * 2.0 = 10.0
|
||||
assert!((score - 10.0_f32).abs() < 0.001, "score={score}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
};
|
||||
|
||||
|
||||
@@ -420,6 +420,8 @@ fn minimal_save() -> SaveStateV1 {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,7 @@ fn snapshot_with_sim_errors_roundtrips() {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: Some(0xDEADBEEF),
|
||||
debug_response: None,
|
||||
sim_errors: vec![
|
||||
SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
|
||||
@@ -50,6 +50,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
}
|
||||
}
|
||||
@@ -244,6 +245,7 @@ fn generate_msgpack_fixtures() {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
@@ -408,6 +410,7 @@ fn generate_msgpack_fixtures() {
|
||||
}),
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -208,6 +208,8 @@ fn save_state_npc_kg_isolation() {
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
contamination_active: false,
|
||||
activated_count: 0,
|
||||
last_activation_tick: None,
|
||||
};
|
||||
|
||||
// Roundtrip: serialize → deserialize.
|
||||
|
||||
@@ -38,6 +38,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
}
|
||||
}
|
||||
@@ -299,6 +300,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
};
|
||||
|
||||
@@ -354,7 +356,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 17,
|
||||
PROTOCOL_VERSION, 18,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -406,6 +408,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
save_result: None,
|
||||
triangle_crisis_events: vec![],
|
||||
state_hash: None,
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
|
||||
Reference in New Issue
Block a user