Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-12 23:40:13 +01:00
116 changed files with 3104 additions and 988 deletions
+109
View File
@@ -0,0 +1,109 @@
---
name: push-pr
description: >
Push commits and create or update a pull request. Use when the user says
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
or invokes /push-pr. NOT triggered by plain "push" (that's just git push).
Pushes the current branch, creates a PR if none exists, or confirms the
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
---
# Push PR Skill
Push commits to remote and create or update a PR. Operates exclusively on the
current branch — never touches main.
## Safety Rules (NON-NEGOTIABLE)
- **NEVER merge a PR into main.** No `tea pr merge`, no `git merge` into main.
- **NEVER checkout or push to main.**
- **NEVER force-push** unless the user explicitly requests it.
- **NEVER use `--no-verify` or skip hooks.**
- Only push to the current working branch.
## Workflow
### 1. Validate branch
```bash
git branch --show-current
```
If on `main`, stop: "You're on main. Switch to a team branch first."
### 2. Check for unpushed commits
```bash
git fetch --all
git status
git log --oneline origin/<branch>..<branch>
```
If no unpushed commits, skip to step 4 (PR check).
### 3. Check for conflicts with main
```bash
git merge-tree --write-tree origin/main HEAD 2>&1
```
If conflicts reported, merge main into current branch:
```bash
git merge origin/main --no-edit
```
If merge conflicts, **stop and report** — let the user resolve.
If clean, continue.
### 4. Push
```bash
git push origin <branch>
```
If push fails, stop and report. Never force-push without explicit request.
### 5. Check for existing PR
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Match current branch name in PR list.
- **PR exists**: Report "Pushed N commits to `<branch>`. PR #X updated." Done.
- **No PR**: Continue to step 6.
### 6. Create a new PR
```bash
git log --oneline main..<branch>
git diff --stat main...<branch>
```
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
```bash
cat > /tmp/pr-body.md << 'EOF'
## Summary
...
EOF
tea pr create \
--repo jpmschweitzer/settled-reach \
--login schweitz \
--title "<title>" \
--description "$(cat /tmp/pr-body.md)" \
--base main \
--head <branch>
```
Report PR URL when done.
## Arguments
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
PR title instead of generating one.
+44
View File
@@ -11,11 +11,55 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- 3D-to-2D sprite render pipeline (`client/tooling/sprite_renderer/`) — Godot @tool scene renders textured 3D models at "the angle" (-72.5deg ortho) from 4 cardinal directions at 1024/256/64 resolutions with outline applied at working resolution
- `/render-sprite` skill — CLI wrapper for the render pipeline with headless import step
- Pipeline POC: Era 1 institutional wall + bar green wall textures generated via Nano Banana and rendered through full pipeline
- PerceptionQuery trait and ActivePerceptionMode resource — abstraction layer for D-017 perception mode swapping (NaturalVision default implementation)
- VisibilityGeometry intermediate resource decoupling FOV computation from entity filtering
- Client-side PROTOCOL_VERSION enforcement — snapshot decoder rejects version mismatches with error log
- POI verb priority test in observer pipeline — asserts both verb kind and priority values end-to-end
### Changed
- Observer pipeline decomposed into two-stage system: compute_visibility_geometry (geometry) → compute_observer_snapshot (entity filtering + assembly)
- POI verb priority adjustment moved from simulation phase (interaction.rs) to perception phase (observer) — fixes D-010 information boundary violation
- compute_nearby_interactions no longer reads KnowledgeGraph — determines verb availability by proximity only, verb priority adjusted by observer
- compute_nearby_interactions scheduling moved from SimulationPlugin to BridgePlugin for explicit ordering with geometry and observer systems
- Client test snapshot updated to v4 format (Protocol.PROTOCOL_VERSION, tick_rate replaces paused)
### Added
- Content validation tooling — `make validate-content` validates campaign YAML files against JSON schemas, maps files by directory context
- Entity::to_bits() roundtrip test — guards against bevy version changes silently breaking wire IDs
- TickRate switch mid-accumulation test — verifies Half→Full→Paused→Half transitions preserve accumulator state
- Tick rate scaling system (#406, D-052) — Full/Half/Paused rates with fractional accumulation, SetTickRate player action, replaces binary pause flag
- Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4
- Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions
- Content schema definitions (#386) — 8 JSON Schema files (draft 2020-12) for district, location, npc-profile, dialogue-pool, monologue-pool, triangle, routine, fact-catalog validation
- PROTOCOL_VERSION constant in bridge types — versioning strategy documented (subprocess IPC, serde defaults for field evolution)
- Campaign, system, station JSON schemas for hierarchical content validation
### Fixed
- Wire entity_id now uses StableId consistently across observer, observation, interpretation, and interaction systems (was Entity::to_bits() in some paths)
- Restored system/station/district hierarchical fields in district metadata (incorrectly removed during canonical_id cleanup)
- Unregistered entities in observer/interaction now log tracing::error instead of silently falling back to Entity::to_bits()
### Changed
- NearbyInteractionBuffer refactored from global Resource to per-entity Component on PlayerCharacter — multiplayer-ready (D-009)
- Observer module split into mod.rs (244 lines) + tests.rs (480 lines) — reduces module complexity
- Content directory restructured from flat districts/ to hierarchical campaigns/main/systems/krenn/stations/sova/districts/transit/ — path mirrors canonical IDs, glob-based discovery, multi-campaign/DLC ready
- Content manifest (content.yaml) rewritten for glob-based district discovery
- District identity fields (system, station, district) now derived from directory path — removed from district.yaml required fields
- NPC canonical_id schema accepts district-scoped IDs (npc:transit.kael-davan) for cross-district uniqueness
- ObserverSnapshot protocol bumped to v4 — adds nearby_interactions field, tick_rate replaces paused field in GameTime
- NearbyInteractionBuffer.interactions is now private with take() accessor (no per-frame clone)
- NearbyInteraction.distance changed from f32 to u32 (matches manhattan distance)
- Missing PlayerCharacter in input processing now panics instead of silent no-op
- Verb sort uses (priority, kind) tuple for deterministic ordering at equal priority
- Unregistered entity in knowledge events triggers debug_assert + error (was warn)
- District schema: canonical_id is now optional (derived from directory path at load time)
- Skills trimmed for CLAUDE.md deduplication — search-docs (-48%), ticket (-17%), review-pr (-35%) now reference CLAUDE.md for basics instead of repeating them
- Review-pr reviewer profiles extracted to `references/reviewer-profiles.md` for progressive disclosure
### Removed
- Dead generate_snapshot function in bridge/mod.rs — superseded by compute_observer_snapshot
- content/global/regions/ directory — region data absorbed into system.yaml metadata
### Added
- Dual Lens Authoring Guide — 7-chapter reference for writing content that works for both smuggler and detective perspectives (D-027, D-028, D-032, D-034, D-035)
- THE MIRROR pattern spec — transparency-as-contrast NPC design with Naia Tamm reference implementation and generator template
+7 -1
View File
@@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build client server test lint ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan \
db-backup db-install
db-backup db-install validate-content
# --- Configuration ---
@@ -30,6 +30,7 @@ help:
@echo " make decisions-coverage Decision-to-ticket coverage by domain"
@echo " make decisions-active List active decisions"
@echo " make decisions-orphan Decisions without implementing tickets"
@echo " make validate-content Validate content YAML against schemas"
@echo ""
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
@@ -127,6 +128,11 @@ decisions-active:
decisions-orphan:
@db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
# --- Content Validation ---
validate-content:
@tooling/validate-content
# --- Clean ---
clean:
+2 -2
View File
@@ -296,12 +296,12 @@ func _test_snapshot() -> Dictionary:
return {
"tick": _test_tick,
"version": 2,
"version": Protocol.PROTOCOL_VERSION,
"game_time": {
"day": 0,
"time_of_day": _test_tick * 10,
"day_phase": "Morning",
"paused": false,
"tick_rate": "Full",
},
"player_facing": _test_facing,
"entities": entities,
+11 -2
View File
@@ -9,6 +9,10 @@ class_name Protocol
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 4
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -27,6 +31,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
push_error("Protocol: snapshot missing required fields")
return null
# Version check: reject snapshots from incompatible server
var version: Variant = raw.get("version")
if version != PROTOCOL_VERSION:
push_error("Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
return null
var entities: Array[Dictionary] = []
var raw_entities: Array = raw["entities"]
var dropped := 0
@@ -44,8 +54,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
var tick: int = raw["tick"]
# v2 fields — optional for backward compatibility
var version: Variant = raw.get("version")
# version already checked above; game_time for HUD display
var game_time: Variant = raw.get("game_time")
# player_facing: FacingDirection is a unit enum → bare string in rmp_serde
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
# Content Infrastructure
Directories prefixed with `_` are infrastructure, not game content. The server content loader skips directories starting with `_` when scanning for content files.
- `_meta/` — Infrastructure metadata (this directory)
- `_schema/` — JSON Schema validation files (draft 2020-12)
## Directory Hierarchy
Content is organized hierarchically to match the game universe:
```
content/
content.yaml # Manifest: campaign list, glob discovery patterns
global/ # Cross-campaign shared content (factions, enums, knowledge)
campaigns/
{campaign}/ # e.g., "main"
campaign.yaml
systems/
{system}/ # e.g., "krenn"
system.yaml
stations/
{station}/ # e.g., "sova"
station.yaml
districts/
{district}/ # e.g., "transit"
district.yaml
npcs/
locations/
triangles/
dialogue/
monologue/
routines/
templates/
```
## Canonical ID Derivation
Identity is derived from directory path at load time — no redundant ID fields in YAML.
- **District:** `{system}.{station}.{district}` (e.g., `krenn.sova.transit`)
- **NPC (within district):** `npc:{slug}` (e.g., `npc:kael-davan`)
- **NPC (cross-district):** `npc:{district}.{slug}` (e.g., `npc:transit.kael-davan`)
- **Location:** `{system}.{station}.{district}.location.{slug}`
## Content Discovery
The loader reads `content.yaml` for enabled campaigns and their glob patterns.
District discovery uses `systems/**/districts/*/district.yaml` — no per-district
manifest entry needed. Adding a district = creating a directory with district.yaml.
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "campaign.schema.json",
"title": "Campaign Metadata",
"description": "Campaign definition file — one per campaign directory (D-003).",
"type": "object",
"required": ["display_name", "description"],
"additionalProperties": false,
"properties": {
"display_name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"version": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
}
}
}
+106
View File
@@ -0,0 +1,106 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "dialogue-pool.schema.json",
"title": "Dialogue Line Pool",
"description": "Tagged dialogue lines scoped by location + role (D-028, D-035).",
"type": "object",
"required": ["location", "role", "lines"],
"additionalProperties": false,
"properties": {
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Location slug this dialogue pool belongs to"
},
"role": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Template role slug (e.g. dock-worker, bar-owner)"
},
"lines": {
"type": "array",
"items": { "$ref": "#/$defs/dialogue_line" },
"minItems": 1
}
},
"$defs": {
"dialogue_line": {
"type": "object",
"required": ["id", "text", "role", "access", "trust", "situation"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_d_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_d_{###}"
},
"text": {
"type": "string",
"minLength": 1
},
"role": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Template role this line belongs to"
},
"access": {
"type": "array",
"items": {
"type": "string",
"enum": ["public", "insider", "authority", "peer", "hostile"]
},
"minItems": 1,
"uniqueItems": true,
"description": "Access tiers this line is available at (multi-tier eligibility)"
},
"trust": {
"type": "string",
"enum": ["surface", "real", "secret"],
"description": "Minimum trust level required"
},
"situation": {
"type": "string",
"enum": [
"arrival", "shift_start", "shift_end", "shift_transition",
"bar_evening", "night_shift", "investigation", "confrontation",
"social", "alone", "emergency", "routine", "observation"
],
"description": "Situation context when this line can fire"
},
"topic": {
"type": "string",
"enum": [
"colleague", "routine", "cargo", "money", "trust",
"danger", "institution", "personal", "investigation"
],
"description": "Topic tag for selection weighting"
},
"mood": {
"type": "string",
"enum": [
"fond", "comfortable", "worried", "suspicious",
"analytical", "conflicted", "concerned", "relieved"
],
"description": "Mood tag for selection weighting"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Freeform tags for additional filtering"
},
"knowledge_grant": {
"type": "object",
"description": "Knowledge the player gains from hearing this line",
"properties": {
"fact_id": { "type": "string" },
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
}
},
"required": ["fact_id", "confidence"]
}
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "district.schema.json",
"title": "District Metadata",
"description": "District definition file — one per district directory (D-036). Identity derived from directory path.",
"type": "object",
"required": ["display_name", "description", "locations", "npc_count"],
"additionalProperties": false,
"properties": {
"canonical_id": {
"type": "string",
"pattern": "^[a-z]+\\.[a-z]+\\.[a-z-]+$",
"description": "Deprecated — derived from directory path at load time. If present, must match {system}.{station}.{district}."
},
"display_name": {
"type": "string",
"minLength": 1
},
"system": {
"type": "string",
"pattern": "^[a-z]+$",
"description": "Deprecated — derived from directory path."
},
"station": {
"type": "string",
"pattern": "^[a-z]+$",
"description": "Deprecated — derived from directory path."
},
"district": {
"type": "string",
"pattern": "^[a-z-]+$",
"description": "Deprecated — derived from directory path."
},
"description": {
"type": "string",
"minLength": 1
},
"locations": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"minItems": 1,
"uniqueItems": true
},
"npc_count": {
"type": "integer",
"minimum": 0
}
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "fact-catalog.schema.json",
"title": "Fact Catalog",
"description": "Fact definitions — one file per fact category in global/knowledge/ (D-035).",
"type": "object",
"required": ["category", "facts"],
"additionalProperties": false,
"properties": {
"category": {
"type": "string",
"description": "Fact category matching the filename"
},
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact" },
"minItems": 1
}
},
"$defs": {
"fact": {
"type": "object",
"required": ["fact_id", "description", "discoverable_by"],
"additionalProperties": false,
"properties": {
"fact_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$",
"description": "Stable unique fact identifier"
},
"description": {
"type": "string",
"minLength": 1
},
"discoverable_by": {
"type": "array",
"items": {
"type": "string",
"enum": ["smuggler", "detective"]
},
"minItems": 1,
"uniqueItems": true,
"description": "Which playable characters can discover this fact"
},
"abstract": {
"type": "boolean",
"default": false,
"description": "If true, fact cannot reach Direct confidence (only inferred)"
},
"progression": {
"type": "array",
"items": { "$ref": "#/$defs/confidence_level" },
"description": "Confidence level descriptions in ascending order"
}
}
},
"confidence_level": {
"type": "object",
"required": ["confidence", "text"],
"additionalProperties": false,
"properties": {
"confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
},
"text": {
"type": "string",
"description": "Player-facing description at this confidence level"
}
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "location.schema.json",
"title": "Location Definition",
"description": "Location metadata — one file per location (D-025, D-036).",
"type": "object",
"required": ["canonical_id", "display_name"],
"additionalProperties": false,
"properties": {
"canonical_id": {
"type": "string",
"pattern": "^[a-z]+\\.[a-z]+\\.[a-z-]+\\.location\\.[a-z][a-z0-9-]*$",
"description": "Full canonical ID: {system}.{station}.{district}.location.{slug}"
},
"display_name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"tile_bounds": {
"type": "object",
"description": "Rectangular tile bounds for this location",
"properties": {
"x_min": { "type": "integer" },
"y_min": { "type": "integer" },
"x_max": { "type": "integer" },
"y_max": { "type": "integer" },
"z": { "type": "integer" }
},
"required": ["x_min", "y_min", "x_max", "y_max", "z"]
},
"sightlines": {
"type": "object",
"description": "Sightline properties for LOS computation",
"properties": {
"open": {
"type": "boolean",
"description": "True if the location is open-plan (no internal walls)"
},
"notes": { "type": "string" }
}
},
"ambient_sound": {
"type": "string",
"description": "Reference to ambient sound asset"
},
"social_site": {
"type": "string",
"description": "Social site template this location belongs to"
}
}
}
+117
View File
@@ -0,0 +1,117 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "monologue-pool.schema.json",
"title": "Monologue Line Pool",
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035).",
"type": "object",
"required": ["character", "location", "lines"],
"additionalProperties": false,
"properties": {
"character": {
"type": "string",
"enum": ["smuggler", "detective"],
"description": "Playable character this pool belongs to (hard partition per D-032)"
},
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Location slug, or 'general' for location-independent lines"
},
"lines": {
"type": "array",
"items": { "$ref": "#/$defs/monologue_line" },
"minItems": 1
}
},
"$defs": {
"monologue_line": {
"type": "object",
"required": ["id", "text", "trigger"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}"
},
"text": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "Line text — 160 char max per D-059"
},
"trigger": {
"type": "string",
"enum": [
"enter_location", "observe_npc", "hear_sound",
"observe_anomaly", "post_conversation", "discover_evidence",
"witness_interaction", "time_idle", "return_visit"
],
"description": "What causes this line to fire"
},
"prerequisites": {
"type": "object",
"description": "AND-only prerequisite conditions",
"properties": {
"facts": {
"type": "array",
"items": { "$ref": "#/$defs/fact_prerequisite" }
},
"entity_attributes": {
"type": "array",
"items": { "$ref": "#/$defs/attribute_prerequisite" }
},
"relationship": {
"type": "object",
"properties": {
"target": { "type": "string" },
"state": {
"type": "string",
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
}
}
}
}
},
"priority": {
"type": "integer",
"minimum": 0,
"maximum": 10,
"default": 5,
"description": "Selection priority (higher = more likely to fire)"
},
"cooldown": {
"type": "integer",
"minimum": 0,
"description": "Minimum ticks before this line can fire again"
},
"tags": {
"type": "array",
"items": { "type": "string" }
}
}
},
"fact_prerequisite": {
"type": "object",
"required": ["fact_id", "min_confidence"],
"additionalProperties": false,
"properties": {
"fact_id": { "type": "string" },
"min_confidence": {
"type": "string",
"enum": ["suspects", "knows_of", "knows_details", "direct"]
}
}
},
"attribute_prerequisite": {
"type": "object",
"required": ["entity", "key", "value"],
"additionalProperties": false,
"properties": {
"entity": { "type": "string" },
"key": { "type": "string" },
"value": { "type": "string" }
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "npc-profile.schema.json",
"title": "NPC Profile",
"description": "10-axis NPC model profile — one file per NPC (D-024, D-034).",
"type": "object",
"required": ["canonical_id", "display_name", "tier", "pattern", "motivation"],
"additionalProperties": false,
"properties": {
"canonical_id": {
"type": "string",
"pattern": "^npc:([a-z][a-z0-9-]+\\.)?[a-z][a-z0-9-]*$",
"description": "Canonical ID: npc:{slug} (within district) or npc:{district}.{slug} (cross-district)"
},
"display_name": {
"type": "string",
"minLength": 1
},
"tier": {
"type": "integer",
"enum": [1, 2, 3],
"description": "NPC tier: 1 = conspiracy (full depth), 2 = template (social context), 3 = filler (atmosphere)"
},
"pattern": {
"type": "string",
"enum": ["FRIEND", "MIRROR", "ANCHOR", "GHOST", "CATALYST", "THRESHOLD", "REMNANT", "SYSTEM", "NOBODY"],
"description": "Thematic pattern — System A (D-050)"
},
"motivation": {
"type": "string",
"enum": ["HANDLER", "WITNESS", "TURNCOAT", "CIVILIAN", "OPERATOR", "SKEPTIC"],
"description": "Functional motivation — System B (D-050)"
},
"description": {
"type": "string"
},
"want": {
"type": "object",
"description": "Primary want/need driving this NPC",
"properties": {
"primary": { "type": "string" },
"intensity": { "type": "integer", "minimum": 0, "maximum": 10 },
"description": { "type": "string" }
},
"required": ["primary"]
},
"secret": {
"type": "string",
"description": "The NPC's hidden truth — what they don't want the player to know"
},
"relationships": {
"type": "array",
"items": { "$ref": "#/$defs/relationship" },
"description": "Named relationships to other NPCs"
},
"tolerance": {
"type": "object",
"description": "Stress tolerance threshold",
"properties": {
"threshold": { "type": "integer" },
"description": { "type": "string" }
}
},
"routine": {
"type": "object",
"description": "Summary of daily routine (full schedule in routines/schedules.yaml)",
"properties": {
"summary": { "type": "string" }
}
},
"information": {
"type": "object",
"description": "What this NPC knows",
"properties": {
"knows": {
"type": "array",
"items": { "type": "string" },
"description": "Fact IDs this NPC knows"
},
"access_tier": {
"type": "string",
"enum": ["public", "insider", "authority", "peer", "hostile"]
}
}
},
"contentment": {
"type": "object",
"properties": {
"level": { "type": "integer", "minimum": -10, "maximum": 10 },
"description": { "type": "string" }
}
},
"personality": {
"type": "object",
"description": "Personality traits and behavioral tendencies",
"additionalProperties": { "type": "string" }
},
"tells": {
"type": "array",
"items": { "$ref": "#/$defs/tell" },
"description": "Observable behavioral tells (D-024)"
},
"skills": {
"type": "object",
"description": "Skill set and combat capability",
"properties": {
"combat_trained": { "type": "boolean" },
"skills": {
"type": "object",
"additionalProperties": { "type": "integer", "minimum": 0, "maximum": 10 }
}
}
},
"triangle_membership": {
"type": "array",
"items": { "type": "string" },
"description": "Triangle slugs this NPC participates in"
},
"trust_levels": {
"type": "object",
"description": "What the NPC reveals at each trust level",
"properties": {
"surface": { "type": "string" },
"real": { "type": "string" },
"secret": { "type": "string" }
}
},
"friend_arc": {
"type": "object",
"description": "FRIEND arc data — only valid on FRIEND-pattern Tier 1 NPCs (D-034)",
"properties": {
"bonded_character": {
"type": "string",
"enum": ["smuggler", "detective"]
},
"phases": {
"type": "array",
"items": { "$ref": "#/$defs/friend_phase" }
}
},
"required": ["bonded_character", "phases"]
},
"dual_lens": {
"type": "object",
"description": "Authoring-only: how smuggler vs detective perceives this NPC",
"properties": {
"smuggler": { "type": "string" },
"detective": { "type": "string" }
}
},
"notes": {
"type": "string",
"description": "Authoring-only: design notes"
}
},
"if": {
"properties": { "pattern": { "const": "FRIEND" } }
},
"then": {
"required": ["friend_arc"]
},
"$defs": {
"relationship": {
"type": "object",
"required": ["target", "kind"],
"additionalProperties": false,
"properties": {
"target": {
"type": "string",
"pattern": "^npc:([a-z][a-z0-9-]+\\.)?[a-z][a-z0-9-]*$"
},
"kind": {
"type": "string",
"enum": ["colleague", "friend", "rival", "romantic", "family", "superior", "subordinate"]
},
"trust": {
"type": "integer",
"minimum": -10,
"maximum": 10
},
"notes": { "type": "string" }
}
},
"tell": {
"type": "object",
"required": ["trigger", "behavior"],
"additionalProperties": false,
"properties": {
"trigger": { "type": "string" },
"behavior": { "type": "string" },
"visible_to": {
"type": "string",
"enum": ["forward", "peripheral", "any"]
}
}
},
"friend_phase": {
"type": "object",
"required": ["phase", "description"],
"additionalProperties": false,
"properties": {
"phase": { "type": "integer", "minimum": 1, "maximum": 5 },
"description": { "type": "string" },
"trigger": { "type": "string" },
"routine_deviation": { "type": "string" }
}
}
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "routine.schema.json",
"title": "NPC Routine Schedules",
"description": "Daily routine schedules — all NPCs in one file per district for cross-NPC validation (D-034).",
"type": "object",
"required": ["district", "schedules"],
"additionalProperties": false,
"properties": {
"district": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "District slug this routine file belongs to"
},
"schedules": {
"type": "array",
"items": { "$ref": "#/$defs/npc_schedule" },
"minItems": 1
}
},
"$defs": {
"npc_schedule": {
"type": "object",
"required": ["npc", "entries"],
"additionalProperties": false,
"properties": {
"npc": {
"type": "string",
"pattern": "^npc:[a-z][a-z0-9-]*$",
"description": "NPC short-form canonical ID"
},
"entries": {
"type": "array",
"items": { "$ref": "#/$defs/routine_entry" },
"minItems": 1
},
"deviations": {
"type": "array",
"items": { "$ref": "#/$defs/deviation" },
"description": "Conditional schedule overrides (FRIEND arc staging, etc.)"
}
}
},
"routine_entry": {
"type": "object",
"required": ["phase", "location"],
"additionalProperties": false,
"properties": {
"phase": {
"type": "string",
"enum": ["morning", "afternoon", "evening", "night"],
"description": "Day phase for this entry"
},
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Location slug"
},
"tile": {
"type": "object",
"properties": {
"x": { "type": "integer" },
"y": { "type": "integer" }
},
"required": ["x", "y"],
"description": "Specific tile coordinates within the location"
},
"activity": {
"type": "string",
"description": "What the NPC is doing at this location/time"
}
}
},
"deviation": {
"type": "object",
"required": ["trigger", "location"],
"additionalProperties": false,
"properties": {
"trigger": {
"type": "string",
"description": "Condition that activates this deviation"
},
"phase": {
"type": "string",
"enum": ["morning", "afternoon", "evening", "night"]
},
"location": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"tile": {
"type": "object",
"properties": {
"x": { "type": "integer" },
"y": { "type": "integer" }
},
"required": ["x", "y"]
},
"activity": { "type": "string" }
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "station.schema.json",
"title": "Station Metadata",
"description": "Station definition file — one per station directory (D-036).",
"type": "object",
"required": ["display_name"],
"additionalProperties": false,
"properties": {
"display_name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"districts": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"uniqueItems": true
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "system.schema.json",
"title": "Star System Metadata",
"description": "System definition file — one per system directory (D-036).",
"type": "object",
"required": ["display_name"],
"additionalProperties": false,
"properties": {
"display_name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"stations": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"uniqueItems": true
}
}
}
+99
View File
@@ -0,0 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "triangle.schema.json",
"title": "Triangle Definition",
"description": "3-NPC relationship triangle — self-contained forks, no cross-triangle cascade in v0.1 (D-024, D-047).",
"type": "object",
"required": ["canonical_id", "display_name", "members"],
"additionalProperties": false,
"properties": {
"canonical_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Triangle slug"
},
"display_name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"members": {
"type": "array",
"items": { "$ref": "#/$defs/member" },
"minItems": 3,
"maxItems": 3,
"description": "Exactly 3 NPC members"
},
"forks": {
"type": "array",
"items": { "$ref": "#/$defs/fork" },
"description": "Possible fork points in this triangle"
},
"resolution_states": {
"type": "array",
"items": { "$ref": "#/$defs/resolution" },
"description": "Terminal states this triangle can reach"
}
},
"$defs": {
"member": {
"type": "object",
"required": ["npc", "role"],
"additionalProperties": false,
"properties": {
"npc": {
"type": "string",
"pattern": "^npc:[a-z][a-z0-9-]*$",
"description": "NPC short-form canonical ID"
},
"role": {
"type": "string",
"description": "This NPC's role within the triangle"
}
}
},
"fork": {
"type": "object",
"required": ["id", "condition"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"condition": {
"type": "string",
"description": "What triggers this fork"
},
"outcomes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"description": { "type": "string" },
"effects": {
"type": "array",
"items": { "type": "string" }
}
}
}
}
}
},
"resolution": {
"type": "object",
"required": ["id", "description"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"description": { "type": "string" }
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
# Main Campaign metadata (D-003)
display_name: "The Settled Reach"
description: >
The core campaign. Set in a universe of wormhole-connected star systems,
neural lattice technology, and faction-driven politics. Occlusion-based
detective game with combat elements.
version: "0.1.0"
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: ring-operative at Maintenance Corridors
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bar-owner at The Last Shift
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bar-regular at The Last Shift
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: bartender at The Last Shift
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: courier at The Terminal
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: dock-worker at The Terminal
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: new-hire at The Terminal
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: scheduler at The Terminal
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Dialogue: shift-supervisor at The Terminal
@@ -0,0 +1,13 @@
# Sova Transit District metadata (D-036)
# Identity derived from directory path: campaigns/main/systems/krenn/stations/sova/districts/transit/
# canonical_id: krenn.sova.transit (derived at load time by ContentValidator)
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.
locations:
- "the-terminal"
- "the-last-shift"
- "maintenance-corridors"
npc_count: 17
@@ -0,0 +1,2 @@
# Location: Maintenance Corridors (smuggling spaces)
# canonical_id: krenn.sova.transit.location.maintenance-corridors
@@ -0,0 +1,2 @@
# Location: The Last Shift (bar)
# canonical_id: krenn.sova.transit.location.the-last-shift
@@ -0,0 +1,2 @@
# Location: The Terminal (logistics hub)
# canonical_id: krenn.sova.transit.location.the-terminal
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: detective at general
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: detective at maintenance-corridors
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: detective at the-last-shift
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: detective at the-terminal
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: smuggler at general
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: smuggler at maintenance-corridors
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: smuggler at the-last-shift
@@ -0,0 +1,2 @@
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
# Monologue: smuggler at the-terminal
@@ -0,0 +1,2 @@
# NPC Profile: devra
# canonical_id: krenn.sova.transit.npc.devra
@@ -0,0 +1,2 @@
# NPC Profile: drin
# canonical_id: krenn.sova.transit.npc.drin
@@ -0,0 +1,2 @@
# NPC Profile: harek
# canonical_id: krenn.sova.transit.npc.harek
@@ -0,0 +1,2 @@
# NPC Profile: kael-davan
# canonical_id: krenn.sova.transit.npc.kael-davan
@@ -0,0 +1,2 @@
# NPC Profile: lera-sessik
# canonical_id: krenn.sova.transit.npc.lera-sessik
@@ -0,0 +1,2 @@
# NPC Profile: maret-korr
# canonical_id: krenn.sova.transit.npc.maret-korr
@@ -0,0 +1,2 @@
# NPC Profile: naia-tamm
# canonical_id: krenn.sova.transit.npc.naia-tamm
@@ -0,0 +1,2 @@
# NPC Profile: olin
# canonical_id: krenn.sova.transit.npc.olin
@@ -0,0 +1,2 @@
# NPC Profile: pell
# canonical_id: krenn.sova.transit.npc.pell
@@ -0,0 +1,2 @@
# NPC Profile: renn
# canonical_id: krenn.sova.transit.npc.renn
@@ -0,0 +1,2 @@
# NPC Profile: resha
# canonical_id: krenn.sova.transit.npc.resha
@@ -0,0 +1,2 @@
# NPC Profile: sabel
# canonical_id: krenn.sova.transit.npc.sabel
@@ -0,0 +1,2 @@
# NPC Profile: sera-venn
# canonical_id: krenn.sova.transit.npc.sera-venn
@@ -0,0 +1,2 @@
# NPC Profile: sess
# canonical_id: krenn.sova.transit.npc.sess
@@ -0,0 +1,2 @@
# NPC Profile: tav
# canonical_id: krenn.sova.transit.npc.tav
@@ -0,0 +1,2 @@
# NPC Profile: torek-lintar
# canonical_id: krenn.sova.transit.npc.torek-lintar
@@ -0,0 +1,2 @@
# NPC Profile: voss
# canonical_id: krenn.sova.transit.npc.voss
@@ -0,0 +1,2 @@
# NPC daily routine schedules — Sova Transit District
# All NPC schedules in one file for cross-NPC scheduling validation
@@ -0,0 +1 @@
# Triangle: bar-tensions
@@ -0,0 +1 @@
# Triangle: hub-power
@@ -0,0 +1 @@
# Triangle: informant-question
@@ -0,0 +1 @@
# Triangle: worried-knowledge
@@ -0,0 +1 @@
# Triangle: worried-partner
@@ -0,0 +1,7 @@
# Station Sova metadata (D-036)
display_name: "Station Sova"
description: >
A freight logistics station in the Krenn system. 40-year-old prefab-modular
facility serving the span gate's cargo throughput. ~12,000 population.
districts:
- "transit"
@@ -0,0 +1,8 @@
# Krenn System metadata (D-036)
display_name: "Krenn System"
description: >
A mid-tier system connected via the Sova span gate. Industrial economy
centered on freight logistics and lattice component trade. ~2.4M population,
~180 years settled.
stations:
- "sova"
+11
View File
@@ -0,0 +1,11 @@
# Content manifest — The Settled Reach v0.1
# The server reads this first to discover campaigns and content layout.
# District discovery uses glob patterns — no per-district listing needed.
version: "0.1.0"
campaigns:
- id: "main"
path: "campaigns/main"
enabled: true
discovery:
districts: "systems/**/districts/*/district.yaml"
View File
+1
View File
@@ -0,0 +1 @@
# Access tiers: public, insider, authority, peer, hostile
+1
View File
@@ -0,0 +1 @@
# 8 mood values
+1
View File
@@ -0,0 +1 @@
# 6 functional motivations (System B)
+1
View File
@@ -0,0 +1 @@
# 9 thematic patterns (System A)
+1
View File
@@ -0,0 +1 @@
# 13 situation values (D-035)
+1
View File
@@ -0,0 +1 @@
# 9 topic values
+1
View File
@@ -0,0 +1 @@
# 9 monologue trigger types
+1
View File
@@ -0,0 +1 @@
# Trust tiers: surface, real, secret
@@ -0,0 +1 @@
# Faction: concord-assembly
@@ -0,0 +1 @@
# Faction: guardians-of-autonomy
@@ -0,0 +1 @@
# Faction: lattice-commission
+1
View File
@@ -0,0 +1 @@
# Faction: syndics
+1
View File
@@ -0,0 +1 @@
# Faction: the-ring
+1
View File
@@ -0,0 +1 @@
# Faction: the-unbound
@@ -0,0 +1 @@
# Faction: veil-institute
+1
View File
@@ -0,0 +1 @@
# Fact catalog: contraband
@@ -0,0 +1 @@
# Entity attributes — 16 canonical EntityKnowledge keys (D-055)
@@ -0,0 +1 @@
# Fact catalog: investigation
+1
View File
@@ -0,0 +1 @@
# Fact catalog: location
+1
View File
@@ -0,0 +1 @@
# Fact catalog: progress
@@ -0,0 +1 @@
# Fact catalog: relationship
+1
View File
@@ -0,0 +1 @@
# Fact catalog: world
View File
+10 -59
View File
@@ -63,63 +63,6 @@ impl BridgeResource {
}
}
/// Generate ObserverSnapshot v2 from ECS state.
/// Pre-visibility version: sends ALL entities (no LOS filtering yet).
/// Will be replaced by perception::observer::compute_observer_snapshot in #112.
pub fn generate_snapshot(
time: Res<crate::simulation::time::SimulationTime>,
entities: Query<(
Entity,
&crate::simulation::movement::TilePosition,
Option<&crate::simulation::movement::PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
mut buffer: ResMut<SnapshotBuffer>,
) {
let mut visible = Vec::new();
for (entity, pos, is_player, is_npc) in entities.iter() {
let (x, y, z) = pos.to_render_coords();
let kind = if is_player.is_some() {
EntityKind::Player
} else if is_npc.is_some() {
EntityKind::Npc
} else {
EntityKind::Object
};
visible.push(VisibleEntity {
entity_id: entity.to_bits(),
x,
y,
z,
kind,
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
});
}
let game_time = GameTime {
day: time.day(),
time_of_day: time.time_of_day_minutes(),
day_phase: time.day_phase(),
paused: time.paused,
};
tracing::trace!(
"generate_snapshot: tick={}, entities={}",
time.tick,
visible.len()
);
buffer.snapshot = Some(ObserverSnapshot {
version: 3,
tick: time.tick,
game_time,
player_facing: FacingDirection::default(),
entities: visible,
visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering
});
}
/// Receive inputs from bridge and push to InputQueue
pub fn receive_bridge_inputs(
bridge: Option<Res<BridgeResource>>,
@@ -202,12 +145,20 @@ impl Plugin for BridgePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SnapshotBuffer>()
.init_resource::<ServerRunning>()
.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),
receive_bridge_inputs
.before(crate::simulation::input::process_player_input),
crate::perception::observer::compute_visibility_geometry
.after(crate::simulation::movement::validate_movement),
crate::simulation::interaction::compute_nearby_interactions
.after(crate::simulation::movement::validate_movement),
crate::perception::observer::compute_observer_snapshot
.after(crate::simulation::movement::validate_movement)
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+56 -4
View File
@@ -6,18 +6,28 @@ use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
pub use crate::knowledge::types::{EntityVisibility, KnowledgeConfidence, RelationshipState};
pub use crate::simulation::time::DayPhase;
pub use crate::simulation::time::{DayPhase, TickRate};
/// Wire protocol version for ObserverSnapshot.
///
/// Versioning strategy: flat struct + serde defaults for field evolution.
/// Client and server are co-versioned (subprocess IPC per D-020), so protocol
/// 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 = 4;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
///
/// v2 adds: game_time, player_facing, visible_tiles, visibility sectors.
/// v3 adds: relationship (D-033 entity color), observation (Visible/Remembered).
/// v4 adds: nearby_interactions (D-060, #404 proximity + verbs[]).
/// Future fields: ambient sound events, internal monologue triggers,
/// HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 3.
/// Protocol version for forward compatibility. Current: 4.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -29,6 +39,9 @@ pub struct ObserverSnapshot {
pub entities: Vec<VisibleEntity>,
/// Tiles visible to the observer for fog rendering
pub visible_tiles: Vec<VisibleTile>,
/// Entities within interaction range with available verbs (D-060, #404).
/// Sorted by distance (nearest first). v0.1 client reads verbs[0] on the nearest entity.
pub nearby_interactions: Vec<NearbyInteraction>,
}
/// Game time data for client display (D-031)
@@ -41,8 +54,8 @@ pub struct GameTime {
pub time_of_day: u64,
/// Current day phase (Morning/Afternoon/Evening/Night)
pub day_phase: DayPhase,
/// Whether simulation is paused
pub paused: bool,
/// Current tick rate state (D-052). Client derives paused from TickRate::Paused.
pub tick_rate: TickRate,
}
/// 8-directional facing direction, matching movement system.
@@ -136,6 +149,45 @@ pub enum PlayerAction {
UsePerceptionMode(String),
Pause,
Unpause,
/// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052
SetTickRate(TickRate),
}
/// Available interaction verbs for a nearby entity (D-060, #404)
/// Embedded in ObserverSnapshot.nearby_interactions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NearbyInteraction {
/// Wire-format entity identifier
pub entity_id: u64,
/// Entity type for client-side verb display
pub entity_type: EntityKind,
/// Manhattan distance from player (integer tiles)
pub distance: u32,
/// Available verbs sorted by priority (index 0 = highest priority)
pub verbs: Vec<VerbOption>,
}
/// A single available verb on a nearby entity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerbOption {
/// Verb type
pub kind: VerbKind,
/// Display label for the context prompt (e.g. "Talk", "Observe", "Examine")
pub label: String,
/// Priority rank (lower = higher priority). v0.1 client reads only priority 1.
pub priority: u8,
/// Whether this verb is currently available (false = greyed out in v0.2)
pub available: bool,
}
/// Verb types for the interaction system (D-060)
/// Only active verbs appear in verbs[]. Passive (Look, Overhear) and
/// reactive (Monologue) verbs fire independently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VerbKind {
ExamineObject,
ExamineNpc,
Talk,
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
+6 -4
View File
@@ -79,14 +79,16 @@ pub fn process_knowledge_events(
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity(stable_id, position, event.tick);
} else {
tracing::warn!("DirectObservation target {:?} not in EntityRegistry", target);
debug_assert!(false, "DirectObservation target {:?} not in EntityRegistry", target);
tracing::error!("DirectObservation target {:?} not in EntityRegistry", target);
}
}
KnowledgeEventType::LeftLOS { target } => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.observe_entity_leaving_los(&stable_id, event.tick);
} else {
tracing::warn!("LeftLOS target {:?} not in EntityRegistry", target);
debug_assert!(false, "LeftLOS target {:?} not in EntityRegistry", target);
tracing::error!("LeftLOS target {:?} not in EntityRegistry", target);
}
}
}
@@ -260,7 +262,7 @@ mod tests {
world.insert_resource(thresholds);
// Tick 7: not a multiple of 10, decay should NOT run
world.insert_resource(SimulationTime { tick: 7, paused: false });
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 7; t });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(decay_knowledge);
schedule.run(&mut world);
@@ -273,7 +275,7 @@ mod tests {
);
// Tick 10: multiple of 10, decay SHOULD run (age = 10 > decay_after = 5)
world.insert_resource(SimulationTime { tick: 10, paused: false });
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 10; t });
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
schedule2.add_systems(decay_knowledge);
schedule2.run(&mut world);
+2
View File
@@ -14,6 +14,7 @@ use settled_reach_server::npc::{
Want, WantKind,
};
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::time::DayPhase;
@@ -68,6 +69,7 @@ fn main() {
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);
+40 -32
View File
@@ -110,39 +110,39 @@ pub fn generate_observation_events(
continue;
}
let entity = Entity::from_bits(visible.entity_id);
// Convert wire StableId back to bevy Entity via registry
let stable_id = StableId(visible.entity_id);
let Some(entity) = registry.to_entity(&stable_id) else {
continue;
};
// Check if this is a new entity (not in observer's knowledge graph)
if let Some(stable_id) = registry.to_stable(entity) {
if !observer_kg.knows_entity(&stable_id) {
// Reconstruct tile position from render coords
let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z);
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::NewEntity {
entity: stable_id,
location: tile_pos,
},
observer: observer_entity,
});
}
if !observer_kg.knows_entity(&stable_id) {
// Reconstruct tile position from render coords
let tile_pos = TilePosition::from_render_coords(visible.x, visible.y, visible.z);
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::NewEntity {
entity: stable_id,
location: tile_pos,
},
observer: observer_entity,
});
}
// Check routine deviation: visible NPC not at expected location
if let Ok((actual_pos, routine)) = npc_query.get(entity) {
if let Some(expected_pos) = routine.expected_location(current_phase) {
if *actual_pos != expected_pos {
if let Some(stable_id) = registry.to_stable(entity) {
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::RoutineDeviation {
npc: stable_id,
expected: expected_pos,
actual: *actual_pos,
},
observer: observer_entity,
});
}
event_queue.push(ObservationEvent {
tick: time.tick,
trigger: ObservationTrigger::RoutineDeviation {
npc: stable_id,
expected: expected_pos,
actual: *actual_pos,
},
observer: observer_entity,
});
}
}
}
@@ -157,8 +157,8 @@ pub fn generate_observation_events(
continue;
};
// Skip if currently visible
if visible_npc_bits.contains(&entity.to_bits()) {
// Skip if currently visible (visible_npc_bits contains wire StableId values)
if visible_npc_bits.contains(&stable_id.0) {
continue;
}
@@ -186,7 +186,8 @@ mod tests {
use super::*;
use crate::knowledge::registry::EntityRegistry;
use crate::npc::RoutineEntry;
use crate::perception::observer::compute_observer_snapshot;
use crate::perception::observer::{compute_observer_snapshot, compute_visibility_geometry};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use crate::perception::vision_cone::Facing;
use crate::simulation::movement::WalkabilityMap;
use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
@@ -199,16 +200,18 @@ mod tests {
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<ObservationEventQueue>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world
}
/// Run the observation pipeline: snapshot -> emit -> interpret -> knowledge update.
/// Interpretation runs BEFORE knowledge updates so it can detect new entities
/// and compare against the PREVIOUS tick's knowledge state.
/// Run the observation pipeline: geometry -> snapshot -> emit -> interpret -> knowledge.
fn run_pipeline(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
compute_observer_snapshot,
compute_visibility_geometry,
compute_observer_snapshot
.after(compute_visibility_geometry),
crate::perception::observation::emit_observation_events
.after(compute_observer_snapshot),
generate_observation_events
@@ -234,6 +237,7 @@ mod tests {
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);
@@ -284,6 +288,7 @@ mod tests {
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
crate::simulation::interaction::NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);
@@ -354,6 +359,7 @@ mod tests {
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
crate::simulation::interaction::NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);
@@ -386,6 +392,7 @@ mod tests {
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(), // Empty — never seen anyone
crate::simulation::interaction::NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);
@@ -432,6 +439,7 @@ mod tests {
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
kg,
crate::simulation::interaction::NearbyInteractionBuffer::default(),
))
.id();
registry.register(player);

Some files were not shown because too many files have changed in this diff Show More