Merge remote-tracking branch 'origin/main' into copy
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: >
|
||||
Review a branch diff with dual agents before merge. Use when the user says
|
||||
"review-pr", "review this PR", "review this branch", or invokes /review-pr.
|
||||
Spawns Hoshe (code quality) and Tyre (architecture) in parallel to review
|
||||
the diff against main. Reports approve/reject with inline comments.
|
||||
Review a branch diff with team-appropriate agents before merge. Use when the
|
||||
user says "review-pr", "review this PR", "review this branch", or invokes
|
||||
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
audio) in parallel. Reports approve/reject with inline comments.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Task
|
||||
---
|
||||
|
||||
# PR Review Skill
|
||||
|
||||
Dual-agent review of a branch diff against main. Hoshe reviews code quality,
|
||||
Tyre reviews architecture. Both must approve for a clean review.
|
||||
Multi-agent review of a branch diff against main. Reviewer composition depends
|
||||
on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -32,7 +32,21 @@ Fetch remote branches first:
|
||||
git fetch --all
|
||||
```
|
||||
|
||||
### 2. Generate the diff
|
||||
### 2. Determine reviewer team
|
||||
|
||||
Map the branch name to a reviewer set. Use the branch prefix (before any `/`
|
||||
or `-` suffix) to classify:
|
||||
|
||||
| Branch type | Branches | Reviewers |
|
||||
|-------------|----------|-----------|
|
||||
| **code** | `server`, `client`, `ci`, or unknown | Hoshe (code quality) + Tyre (architecture) |
|
||||
| **copy** | `copy` | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
|
||||
| **visual** | `visual` | Hoshe (QA) + Araminta (art direction) |
|
||||
| **audio** | `audio` | Hoshe (QA) + Ozzie (player experience) |
|
||||
|
||||
If the branch name doesn't match any known type, default to **code** reviewers.
|
||||
|
||||
### 3. Generate the diff and read source files
|
||||
|
||||
```bash
|
||||
git log --oneline main..<branch>
|
||||
@@ -49,75 +63,66 @@ 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. Have Tyre read source files directly from the branch:
|
||||
2. Read source files directly from the branch:
|
||||
`git show origin/<branch>:<path>`
|
||||
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews. Read files with
|
||||
`git show origin/<branch>:<path>` and include them in the prompt.
|
||||
|
||||
### 3. Spawn both reviewers in parallel
|
||||
**IMPORTANT — agent tool access:** Not all reviewer agents have Bash access.
|
||||
Agents that CAN read from branches themselves: **Hoshe, Tyre, Araminta**.
|
||||
Agents that CANNOT (no Bash tool): **Paula, Miri, Ozzie, Gestalt, Gore, Nigel**.
|
||||
|
||||
Use the Task tool to spawn **two agents simultaneously** in a single message.
|
||||
Use `model: sonnet` for both — sufficient for review, saves cost.
|
||||
For agents without Bash, you MUST read the source files yourself (via
|
||||
`git show origin/<branch>:<path>`) and **paste the file contents directly
|
||||
into the agent prompt**. Do not tell these agents to read files — they can't.
|
||||
For very large PRs, read the key files (new/heavily modified) and include
|
||||
summaries or excerpts of minor changes. Also read and include the relevant
|
||||
`decisions/*.md` files these agents need for context.
|
||||
|
||||
**Agent 1 — Hoshe (Code Quality)**
|
||||
- `subagent_type`: `hoshe`
|
||||
- `model`: `sonnet`
|
||||
- Prompt: Include source code and commit log. Ask Hoshe to review for:
|
||||
- Correctness and bug risks
|
||||
- Error handling gaps
|
||||
- Test coverage (are new features tested?)
|
||||
- Code style and clarity
|
||||
- Security concerns (OWASP top 10, injection risks)
|
||||
- Performance issues
|
||||
- Request structured verdict: APPROVE or REQUEST_CHANGES with file-specific comments
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
**Agent 2 — Tyre (Architecture)**
|
||||
- `subagent_type`: `tyre`
|
||||
- `model`: `sonnet`
|
||||
- Prompt: Include source code and commit log. Tell Tyre to read the relevant
|
||||
`decisions/*.md` files first, then review for:
|
||||
- Architectural consistency with project decisions
|
||||
- API/interface design quality
|
||||
- Dependency and coupling concerns
|
||||
- Scalability implications
|
||||
- Whether the change respects non-negotiable baselines (D-010, D-012)
|
||||
- Tyre can read files directly from the branch using `git show origin/<branch>:<path>`
|
||||
- Request structured verdict: APPROVE or REQUEST_CHANGES with file-specific comments
|
||||
Use the Task tool to spawn **all reviewers simultaneously** in a single message.
|
||||
|
||||
### 4. Present results
|
||||
Read `references/reviewer-profiles.md` for the full per-branch-type reviewer
|
||||
specifications (agent types, models, prompt focus areas). Match the branch type
|
||||
from step 2 to the corresponding section.
|
||||
|
||||
Format the combined review as a table per reviewer:
|
||||
All reviewers: request structured verdict: APPROVE or REQUEST_CHANGES with
|
||||
file-specific comments.
|
||||
|
||||
### 5. Present results
|
||||
|
||||
Format the combined review as a table per reviewer. Include one section per
|
||||
reviewer that was spawned (2 for code/visual/audio, 3 for copy):
|
||||
|
||||
```
|
||||
## Review: <branch> -> main
|
||||
## Review: <branch> -> main (type: code|copy|visual|audio)
|
||||
|
||||
### Hoshe (Code Quality): [APPROVE | REQUEST_CHANGES]
|
||||
### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES]
|
||||
[Summary]
|
||||
| # | File | Severity | Issue |
|
||||
|---|------|----------|-------|
|
||||
| 1 | path:line | critical/warning/suggestion | description |
|
||||
|
||||
### Tyre (Architecture): [APPROVE | REQUEST_CHANGES]
|
||||
[Summary]
|
||||
| # | File | Severity | Issue |
|
||||
|---|------|----------|-------|
|
||||
| 1 | path:line | critical/warning/suggestion | description |
|
||||
### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES]
|
||||
...
|
||||
|
||||
### Verdict: [APPROVED | CHANGES REQUESTED]
|
||||
```
|
||||
|
||||
The overall verdict is APPROVED only if **both** reviewers approve.
|
||||
The overall verdict is APPROVED only if **all** reviewers approve.
|
||||
|
||||
## Prompt template for reviewers
|
||||
|
||||
Use this structure when constructing the agent prompts (adapt as needed):
|
||||
|
||||
```
|
||||
Review the following branch diff for merge into main.
|
||||
Review the following {branch_type} branch diff for merge into main.
|
||||
|
||||
Branch: {branch}
|
||||
Branch type: {branch_type} (code|copy|visual|audio)
|
||||
Commits:
|
||||
{commit_log}
|
||||
|
||||
@@ -126,7 +131,7 @@ Diff stats:
|
||||
|
||||
[Source files or diff here — exclude vendor/generated code]
|
||||
|
||||
Review focus: {focus_area}
|
||||
Your review focus: {focus_area}
|
||||
|
||||
Respond with:
|
||||
1. Verdict: APPROVE or REQUEST_CHANGES
|
||||
@@ -138,7 +143,7 @@ Respond with:
|
||||
If no issues found, say APPROVE with a brief positive summary.
|
||||
```
|
||||
|
||||
## Posting results to Gitea
|
||||
## 6. Posting results to Gitea
|
||||
|
||||
After presenting results to the user, post the review as a PR comment:
|
||||
|
||||
@@ -149,7 +154,7 @@ tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "<re
|
||||
Use a heredoc for multi-line review bodies:
|
||||
```bash
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat <<'REVIEW'
|
||||
## Dual-Agent Review: <branch> -> main
|
||||
## Review: <branch> -> main
|
||||
...review content...
|
||||
REVIEW
|
||||
)"
|
||||
@@ -163,7 +168,7 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review.md)"
|
||||
```
|
||||
|
||||
## Merging approved PRs
|
||||
## 7. Merging approved PRs
|
||||
|
||||
`tea pr merge` fails (405) when branches have conflicts with main. Merge
|
||||
locally instead:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Reviewer Profiles by Branch Type
|
||||
|
||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
**Hoshe (Code Quality)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include source code and commit log. Ask Hoshe to review for:
|
||||
- Correctness and bug risks
|
||||
- Error handling gaps
|
||||
- Test coverage (are new features tested?)
|
||||
- Code style and clarity
|
||||
- Security concerns (OWASP top 10, injection risks)
|
||||
- Performance issues
|
||||
|
||||
**Tyre (Architecture)**
|
||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||
- Prompt: Include source code and commit log. Tell Tyre to read the relevant
|
||||
`decisions/*.md` files first, then review for:
|
||||
- Architectural consistency with project decisions
|
||||
- API/interface design quality
|
||||
- Dependency and coupling concerns
|
||||
- Scalability implications
|
||||
- Whether the change respects non-negotiable baselines (D-010, D-012)
|
||||
- Tyre can read files directly from the branch using `git show origin/<branch>:<path>`
|
||||
|
||||
## Copy reviews (`copy`)
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
||||
- Formatting consistency (markdown, file naming, frontmatter)
|
||||
- Broken references or links
|
||||
- Spelling and grammar
|
||||
- File organization and structure
|
||||
- Missing or orphaned files
|
||||
|
||||
**Paula (Narrative Depth)** — NO BASH ACCESS
|
||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||
- Paula cannot read from branches. You must paste file contents and decision
|
||||
files directly into the prompt.
|
||||
- Prompt: Include full text of changed files, commit log, and relevant
|
||||
`decisions/*.md` content. Ask Paula to review for:
|
||||
- Narrative quality and character voice consistency
|
||||
- Whether dialogue and monologue feel authentic to the characters
|
||||
- Consequences and stakes — do choices carry weight?
|
||||
- Political and interpersonal depth
|
||||
- Emotional resonance — does the text make you feel something?
|
||||
|
||||
**Miri (World Consistency)** — NO BASH ACCESS
|
||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||
- Miri cannot read from branches. You must paste file contents and decision
|
||||
files directly into the prompt.
|
||||
- Prompt: Include full text of changed files, commit log, and relevant
|
||||
`decisions/*.md` content. Ask Miri to review for:
|
||||
- Lore accuracy — do facts match established setting?
|
||||
- Internal consistency across files
|
||||
- IP originality — nothing should read as a copy from another franchise
|
||||
- Faction, technology, and location details match the worldbuilding docs
|
||||
- Setting serves gameplay mechanics (asymmetric information, perception)
|
||||
|
||||
## Visual reviews (`visual`)
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
||||
- File format and naming conventions
|
||||
- Asset organization and directory structure
|
||||
- Missing or broken references in scene/resource files
|
||||
- Import settings consistency
|
||||
|
||||
**Araminta (Art Direction)** — HAS BASH ACCESS
|
||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Tell Araminta to read
|
||||
the style guide and relevant design docs first, then review for:
|
||||
- Visual consistency with the established style guide
|
||||
- Color palette adherence
|
||||
- UI pattern consistency (diegetic-first, clarity over beauty)
|
||||
- Whether assets scale gracefully (boxes-with-labels to full-art)
|
||||
- Mood and tone — sleek, advanced, subtle Commonwealth aesthetic
|
||||
|
||||
## Audio reviews (`audio`)
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
||||
- File format and naming conventions
|
||||
- Audio asset organization and directory structure
|
||||
- Missing or broken references
|
||||
- Import/bus configuration consistency
|
||||
|
||||
**Ozzie (Player Experience)** — NO BASH ACCESS
|
||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||
- Ozzie cannot read from branches. You must paste file contents directly
|
||||
into the prompt.
|
||||
- Prompt: Include full text of changed files and commit log. Ask Ozzie to
|
||||
review for:
|
||||
- Emotional impact — does the audio enhance the moment?
|
||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||
- Player feedback clarity — can the player tell what just happened?
|
||||
- Pacing — do sounds support or fight the gameplay rhythm?
|
||||
- Memorable moments — will players remember these audio cues?
|
||||
@@ -10,82 +10,39 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Search Docs Skill
|
||||
|
||||
Semantic search across Commonwealth project documents using Qdrant vector database and ollama embeddings.
|
||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
||||
|
||||
## Access Method
|
||||
|
||||
**Use the connector wrapper scripts:**
|
||||
```bash
|
||||
db/connectors/qdrant-search "query text"
|
||||
db/connectors/qdrant-index <file>
|
||||
db/connectors/qdrant-health
|
||||
db/connectors/qdrant-count
|
||||
```
|
||||
|
||||
Or the Python script directly:
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py <command> [args]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Search
|
||||
Find documents semantically related to a query:
|
||||
```bash
|
||||
db/connectors/qdrant-search "asymmetric information design"
|
||||
db/connectors/qdrant-search "what did we decide about fog of war"
|
||||
db/connectors/qdrant-search "engine requirements"
|
||||
```
|
||||
|
||||
Returns top 5 matching document chunks with source file, heading, and relevance score.
|
||||
|
||||
### Index a file
|
||||
Add or update a document in the search index:
|
||||
```bash
|
||||
db/connectors/qdrant-index decisions/architecture.md
|
||||
db/connectors/qdrant-index decisions/perception.md
|
||||
db/connectors/qdrant-index docs/discussions/round-10-map-fog-borderless.md
|
||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
||||
```
|
||||
|
||||
Files are chunked by markdown headings (# and ##). Each chunk is embedded via ollama and stored in Qdrant with metadata (source_file, heading, chunk_index).
|
||||
## Advanced Commands
|
||||
|
||||
### Index a single chunk
|
||||
|
||||
For precise indexing of specific content:
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
|
||||
```
|
||||
|
||||
### Health check
|
||||
Verify connectivity to Qdrant and ollama:
|
||||
```bash
|
||||
db/connectors/qdrant-health
|
||||
```
|
||||
|
||||
### Collection info
|
||||
Check how many documents are indexed:
|
||||
```bash
|
||||
db/connectors/qdrant-count
|
||||
```
|
||||
|
||||
### Create collection
|
||||
|
||||
Initialize the Qdrant collection (run once during setup):
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py create-collection
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
## Bulk Indexing
|
||||
|
||||
Configured in `db/connectors/config.json`:
|
||||
- **Qdrant:** `http://tower-of-joy:6333`
|
||||
- **Ollama:** `http://tower-of-joy:11434` (model: nomic-embed-text)
|
||||
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||
Index all project documents at once:
|
||||
```bash
|
||||
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
|
||||
db/connectors/qdrant-index "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Fallback
|
||||
|
||||
If Qdrant or ollama is unreachable, fall back to grep-based search:
|
||||
```bash
|
||||
# Search across all project docs
|
||||
grep -r -i "search term" decisions/ DISCUSSION.md docs/ --include="*.md"
|
||||
```
|
||||
|
||||
@@ -96,11 +53,3 @@ grep -r -i "search term" decisions/ DISCUSSION.md docs/ --include="*.md"
|
||||
3. After briefing updates, re-index affected briefings
|
||||
4. After decision changes, re-index the relevant decisions/*.md domain files
|
||||
5. Use search to answer "did we discuss this?" questions with citations
|
||||
|
||||
## Bulk indexing
|
||||
To index all project documents at once:
|
||||
```bash
|
||||
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
|
||||
db/connectors/qdrant-index "$f"
|
||||
done
|
||||
```
|
||||
|
||||
@@ -10,39 +10,17 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Ticket Skill
|
||||
|
||||
Manage the project ticketing database (shared `settledreach.db` in the worktree parent directory).
|
||||
|
||||
## Access Method
|
||||
|
||||
**Use the `ticket` CLI for all ticket operations:**
|
||||
```bash
|
||||
db/connectors/ticket <command> [args...]
|
||||
db/connectors/ticket --help
|
||||
```
|
||||
|
||||
All output is JSON on stdout.
|
||||
|
||||
For raw SQL access (rare), use the wrapper scripts:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `db/connectors/sqlite-query "<SQL>"` | Run SELECT queries |
|
||||
| `db/connectors/sqlite-exec "<SQL>"` | Run INSERT/UPDATE/DELETE |
|
||||
| `db/connectors/sqlite-init` | Create/update database from schema |
|
||||
| `db/connectors/sqlite-seed` | Seed initiatives from DECISIONS.md |
|
||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
||||
This skill covers the full command reference.
|
||||
|
||||
## Commands
|
||||
|
||||
### List tickets
|
||||
### List tickets (full flags)
|
||||
```bash
|
||||
db/connectors/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
```
|
||||
|
||||
### Show ticket detail
|
||||
```bash
|
||||
db/connectors/ticket show <id>
|
||||
```
|
||||
Returns full ticket with children, blockers, and dependents.
|
||||
|
||||
### Create ticket
|
||||
```bash
|
||||
db/connectors/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
|
||||
@@ -88,6 +66,11 @@ db/connectors/ticket children <id>
|
||||
db/connectors/ticket count [--status S]
|
||||
```
|
||||
|
||||
### Batch show
|
||||
```bash
|
||||
db/connectors/ticket show --brief <id> [<id>...]
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **SI (Project Manager)** is the primary user of this skill
|
||||
|
||||
@@ -6,6 +6,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- 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
|
||||
|
||||
### 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
|
||||
@@ -18,8 +22,22 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- v0.1 wow moments checklist — maps all 6 D-039 moments to content deliverables, tickets, dependencies, completion status
|
||||
- Nils Davan off-stage NPC stub — ring coordinator, GHOST + HANDLER pattern, lattice message design, relationship map
|
||||
- NPC pattern/motivation mapping applied to all 18 NPC wiki pages with composition reads
|
||||
- Structured NPC data model (#86) — replaced stub string/f32 fields with typed enums and integer types for D-010 determinism (WantKind, SecretSeverity, Skill, PersonalityTrait, CombatStyle, RelationshipKind)
|
||||
- Global RelationshipGraph resource (#87) — BTreeMap with tuple key for efficient prefix queries and reverse lookups
|
||||
- A* pathfinding system (#237) — PathRequest/ComputedPath/PathBlocked components with cardinal-neighbor A* and manhattan heuristic
|
||||
- NPC path following system (#238) — MovementSpeed throttling, per-tick path advancement with MoveIntent creation
|
||||
- Daily routine system (#88) — NpcPlugin with PreviousDayPhase resource and check_phase_transition system issuing PathRequests at day-phase boundaries
|
||||
- Multiple NPC spawning (#84) — 3 distinct NPCs (dock worker, field tech, guard) with full component bundles and RelationshipGraph edges
|
||||
- Observation event generator (#239) — RoutineDeviation, Absence, and NewEntity triggers from comparing visible snapshot against NPC routines and knowledge state
|
||||
- Review-pr skill routes reviewers by branch type — server/client get Hoshe+Tyre, copy gets Hoshe+Paula+Miri, visual gets Hoshe+Araminta, audio gets Hoshe+Ozzie
|
||||
- Start-sprint skill spawns team agents from sprint briefings — parses the Agents line, creates a team with tasks from tickets, and launches all listed agents as background teammates
|
||||
|
||||
### Fixed
|
||||
- IPC error handling (#341) — DeserializationWithDump/MutexPoisoned error variants, hex dump logging on deserialization failure, graceful error classification in bridge I/O systems
|
||||
- NpcPlugin system ordering — routine phase transitions now run before pathfinding so PathRequests are picked up same frame
|
||||
- Stale doc comment in observation event generator — system runs before knowledge updates, not after
|
||||
- Clippy warnings from Rust 1.93 — derive Default, is_multiple_of, collapsible if
|
||||
|
||||
### Changed
|
||||
- Hael renamed to Naia Tamm across all wiki files (16 files updated)
|
||||
- Canonical full names applied to 11 single-name NPCs (Voss→Arvo Voss, Devra→Devra Talsen, etc.)
|
||||
|
||||
Generated
+42
@@ -378,6 +378,18 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecate-until"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a3767f826efbbe5a5ae093920b58b43b01734202be697e1354914e862e8e704"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -595,6 +607,15 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "integer-sqrt"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
@@ -701,6 +722,20 @@ version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "pathfinding"
|
||||
version = "4.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ac35caa284c08f3721fb33c2741b5f763decaf42d080c8a6a722154347017e"
|
||||
dependencies = [
|
||||
"deprecate-until",
|
||||
"indexmap",
|
||||
"integer-sqrt",
|
||||
"num-traits",
|
||||
"rustc-hash",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -846,6 +881,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -904,6 +945,7 @@ dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bincode",
|
||||
"pathfinding",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rmp-serde",
|
||||
|
||||
@@ -11,6 +11,7 @@ rmp-serde = "1"
|
||||
bincode = "1"
|
||||
rand = "0.9"
|
||||
rand_chacha = "0.9"
|
||||
pathfinding = "4.11"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -83,7 +83,10 @@ impl SimBridge for LocalBridge {
|
||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||
let payload = rmp_serde::to_vec_named(snapshot)?;
|
||||
|
||||
let mut writer = self.writer.lock().expect("writer mutex poisoned");
|
||||
let mut writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
||||
write_framed(writer.get_mut(), &payload)?;
|
||||
|
||||
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
|
||||
@@ -91,14 +94,33 @@ impl SimBridge for LocalBridge {
|
||||
}
|
||||
|
||||
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
|
||||
let mut reader = self.reader.lock().expect("reader mutex poisoned");
|
||||
let mut reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
|
||||
|
||||
match read_framed(reader.get_mut())? {
|
||||
Some(payload) => {
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&payload)?;
|
||||
tracing::trace!("received {} inputs", inputs.len());
|
||||
Ok(inputs)
|
||||
}
|
||||
Some(payload) => match rmp_serde::from_slice::<Vec<PlayerInput>>(&payload) {
|
||||
Ok(inputs) => {
|
||||
tracing::trace!("received {} inputs", inputs.len());
|
||||
Ok(inputs)
|
||||
}
|
||||
Err(e) => {
|
||||
let dump_len = payload.len().min(256);
|
||||
tracing::error!(
|
||||
"deserialization failed: {}. Raw bytes ({} of {} total): {:02x?}",
|
||||
e,
|
||||
dump_len,
|
||||
payload.len(),
|
||||
&payload[..dump_len]
|
||||
);
|
||||
Err(BridgeError::DeserializationWithDump(format!(
|
||||
"{} (payload {} bytes)",
|
||||
e,
|
||||
payload.len()
|
||||
)))
|
||||
}
|
||||
},
|
||||
None => Err(BridgeError::Disconnected),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,16 @@ pub enum BridgeError {
|
||||
Serialization(#[from] rmp_serde::encode::Error),
|
||||
#[error("deserialization error: {0}")]
|
||||
Deserialization(#[from] rmp_serde::decode::Error),
|
||||
#[error("deserialization error (raw bytes logged): {0}")]
|
||||
DeserializationWithDump(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("transport error: {0}")]
|
||||
Transport(String),
|
||||
#[error("client disconnected")]
|
||||
Disconnected,
|
||||
#[error("internal mutex poisoned: {0}")]
|
||||
MutexPoisoned(String),
|
||||
}
|
||||
|
||||
/// Abstracts transport layer (D-020)
|
||||
@@ -133,13 +137,29 @@ pub fn receive_bridge_inputs(
|
||||
tracing::info!("Client disconnected, shutting down");
|
||||
running.0 = false;
|
||||
}
|
||||
Err(BridgeError::Io(ref e))
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe
|
||||
|| e.kind() == std::io::ErrorKind::ConnectionReset =>
|
||||
{
|
||||
tracing::info!("Pipe broken, shutting down cleanly");
|
||||
running.0 = false;
|
||||
}
|
||||
Err(BridgeError::MutexPoisoned(ref msg)) => {
|
||||
tracing::error!("Bridge mutex poisoned: {}. Shutting down.", msg);
|
||||
running.0 = false;
|
||||
}
|
||||
Err(BridgeError::DeserializationWithDump(ref msg)) => {
|
||||
// Recoverable: skip this frame's input, don't shut down
|
||||
tracing::error!("Skipping malformed input frame: {}", msg);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Bridge receive error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send snapshot from buffer to bridge
|
||||
/// Send snapshot from buffer to bridge.
|
||||
/// Any send error is fatal — the client cannot proceed without snapshots.
|
||||
pub fn send_bridge_snapshot(
|
||||
bridge: Option<Res<BridgeResource>>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
@@ -148,7 +168,17 @@ pub fn send_bridge_snapshot(
|
||||
let Some(bridge) = bridge else { return };
|
||||
if let Some(snapshot) = buffer.snapshot.take() {
|
||||
if let Err(e) = bridge.send_snapshot(&snapshot) {
|
||||
tracing::error!("Bridge send error: {}", e);
|
||||
match &e {
|
||||
BridgeError::Disconnected => {
|
||||
tracing::info!("Client disconnected during send, shutting down");
|
||||
}
|
||||
BridgeError::MutexPoisoned(msg) => {
|
||||
tracing::error!("Bridge mutex poisoned during send: {}", msg);
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Bridge send error: {}", e);
|
||||
}
|
||||
}
|
||||
running.0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,10 @@ impl SimBridge for TcpBridge {
|
||||
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
|
||||
let payload = rmp_serde::to_vec_named(snapshot)?;
|
||||
|
||||
let mut writer = self.writer.lock().expect("writer mutex poisoned");
|
||||
let mut writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
||||
write_framed(writer.get_mut(), &payload)?;
|
||||
|
||||
tracing::trace!("sent snapshot: tick={}", snapshot.tick);
|
||||
@@ -130,14 +133,33 @@ impl SimBridge for TcpBridge {
|
||||
}
|
||||
|
||||
fn receive_inputs(&self) -> Result<Vec<PlayerInput>, BridgeError> {
|
||||
let mut reader = self.reader.lock().expect("reader mutex poisoned");
|
||||
let mut reader = self
|
||||
.reader
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("reader: {}", e)))?;
|
||||
|
||||
match read_framed(reader.get_mut())? {
|
||||
Some(payload) => {
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&payload)?;
|
||||
tracing::trace!("received {} inputs", inputs.len());
|
||||
Ok(inputs)
|
||||
}
|
||||
Some(payload) => match rmp_serde::from_slice::<Vec<PlayerInput>>(&payload) {
|
||||
Ok(inputs) => {
|
||||
tracing::trace!("received {} inputs", inputs.len());
|
||||
Ok(inputs)
|
||||
}
|
||||
Err(e) => {
|
||||
let dump_len = payload.len().min(256);
|
||||
tracing::error!(
|
||||
"deserialization failed: {}. Raw bytes ({} of {} total): {:02x?}",
|
||||
e,
|
||||
dump_len,
|
||||
payload.len(),
|
||||
&payload[..dump_len]
|
||||
);
|
||||
Err(BridgeError::DeserializationWithDump(format!(
|
||||
"{} (payload {} bytes)",
|
||||
e,
|
||||
payload.len()
|
||||
)))
|
||||
}
|
||||
},
|
||||
None => Err(BridgeError::Disconnected),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ pub struct GameTime {
|
||||
|
||||
/// 8-directional facing direction, matching movement system.
|
||||
/// Used for vision cone computation (D-015) and snapshot wire format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum FacingDirection {
|
||||
#[default]
|
||||
North,
|
||||
Northeast,
|
||||
East,
|
||||
@@ -59,12 +60,6 @@ pub enum FacingDirection {
|
||||
Northwest,
|
||||
}
|
||||
|
||||
impl Default for FacingDirection {
|
||||
fn default() -> Self {
|
||||
FacingDirection::North
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile visible to the observer with its visibility quality
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VisibleTile {
|
||||
|
||||
@@ -100,7 +100,7 @@ pub fn decay_knowledge(
|
||||
mut knowledge_query: Query<&mut KnowledgeGraph>,
|
||||
) {
|
||||
// Decay runs every 10 ticks (1 game-minute per D-031)
|
||||
if time.tick % 10 != 0 {
|
||||
if !time.tick.is_multiple_of(10) {
|
||||
return;
|
||||
}
|
||||
for mut kg in knowledge_query.iter_mut() {
|
||||
|
||||
@@ -70,9 +70,10 @@ impl KnowledgeConfidence {
|
||||
|
||||
/// Temporal/logical state of a knowledge entry.
|
||||
/// Orthogonal to confidence: a KnowsDetails entry can be Active or Contradicted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum KnowledgeState {
|
||||
/// Currently believed true. Default state.
|
||||
#[default]
|
||||
Active,
|
||||
/// Conflicting information exists. Both conflicting entries receive this state.
|
||||
/// Triggers monologue event when set. THE FRIEND arc detector.
|
||||
@@ -82,12 +83,6 @@ pub enum KnowledgeState {
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl Default for KnowledgeState {
|
||||
fn default() -> Self {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
|
||||
// --- Knowledge Source ---
|
||||
|
||||
/// How knowledge was acquired. Tracked per-entry for provenance.
|
||||
@@ -119,9 +114,10 @@ pub enum SoundRange {
|
||||
/// Relationship state drives D-033 entity color rendering.
|
||||
/// Derived from knowledge + NPC relationship axes (D-024).
|
||||
/// Client maps this to color palette defined in D-033.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum RelationshipState {
|
||||
/// No prior knowledge. Teal #4a9ebb.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// Recognized, neutral-to-positive. Soft green #6bc9a6.
|
||||
Known,
|
||||
@@ -133,12 +129,6 @@ pub enum RelationshipState {
|
||||
Hostile,
|
||||
}
|
||||
|
||||
impl Default for RelationshipState {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entity Knowledge ---
|
||||
|
||||
/// What entity A knows about entity B.
|
||||
@@ -205,9 +195,10 @@ impl Default for DecayThresholds {
|
||||
|
||||
/// How an entity appears in the observer snapshot.
|
||||
/// Extends VisibleEntity for knowledge-based rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum EntityVisibility {
|
||||
/// Currently in line of sight.
|
||||
#[default]
|
||||
Visible,
|
||||
/// Not in LOS but remembered from knowledge graph.
|
||||
Remembered {
|
||||
@@ -215,9 +206,3 @@ pub enum EntityVisibility {
|
||||
age_ticks: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for EntityVisibility {
|
||||
fn default() -> Self {
|
||||
Self::Visible
|
||||
}
|
||||
}
|
||||
|
||||
+154
-11
@@ -6,10 +6,17 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::npc::Npc;
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
|
||||
Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
fn main() {
|
||||
@@ -41,24 +48,160 @@ fn main() {
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
// Proof room: wall at (16,14) between player and NPC
|
||||
// NPC at (16,13) hidden behind wall until player moves around it
|
||||
// Proof room: wall at (16,14) between player and NPC 1
|
||||
{
|
||||
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
|
||||
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
}
|
||||
|
||||
app.world_mut().spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
app.world_mut()
|
||||
.spawn((Npc, TilePosition::new(16, 13, 0)));
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Player at (16,16)
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC 1: Dock worker at (16,13) — behind wall, full routine
|
||||
let npc1 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 13, 0),
|
||||
Want {
|
||||
primary: WantKind::Wealth,
|
||||
intensity: 6,
|
||||
description: "Wants a bigger share of docking fees".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Prep cargo bay".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 10, 0),
|
||||
activity: "Unload freight".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: TilePosition::new(10, 20, 0),
|
||||
activity: "Drink at canteen".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Night,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Sleep in bunk".into(),
|
||||
},
|
||||
],
|
||||
description: "Dock worker shift pattern".into(),
|
||||
},
|
||||
Contentment { level: 20 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 30,
|
||||
threshold: 70,
|
||||
},
|
||||
MovementSpeed::new(2),
|
||||
))
|
||||
.id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
|
||||
// NPC 2: Field tech at (14,18) — visible to player, has routine
|
||||
let npc2 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(14, 18, 0),
|
||||
Want {
|
||||
primary: WantKind::Knowledge,
|
||||
intensity: 8,
|
||||
description: "Obsessed with pre-Collapse sensor arrays".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(14, 18, 0),
|
||||
activity: "Calibrate instruments".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(22, 22, 0),
|
||||
activity: "Field survey".into(),
|
||||
},
|
||||
],
|
||||
description: "Field tech survey pattern".into(),
|
||||
},
|
||||
Contentment { level: 45 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 60,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
|
||||
// NPC 3: Guard at (18,14) — stationary, no routine
|
||||
let npc3 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(18, 14, 0),
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Wants a quiet shift".into(),
|
||||
},
|
||||
Contentment { level: -5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 45,
|
||||
threshold: 55,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc3_sid = registry.register(npc3);
|
||||
|
||||
// Populate RelationshipGraph with a few edges
|
||||
{
|
||||
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
// Dock worker and guard are colleagues with moderate trust
|
||||
rel_graph.set_relationship(
|
||||
npc1_sid,
|
||||
npc3_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 3,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
// Guard distrusts the field tech (rival for resources)
|
||||
rel_graph.set_relationship(
|
||||
npc3_sid,
|
||||
npc2_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Rival,
|
||||
trust: -4,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
|
||||
tracing::info!("Simulation initialized, entering game loop");
|
||||
|
||||
|
||||
+281
-18
@@ -2,76 +2,339 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod relationships;
|
||||
pub mod routine;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::time::DayPhase;
|
||||
|
||||
/// NPC plugin: initializes NPC-related resources and systems.
|
||||
pub struct NpcPlugin;
|
||||
|
||||
impl Plugin for NpcPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<relationships::RelationshipGraph>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.add_systems(
|
||||
Update,
|
||||
routine::check_phase_transition
|
||||
.before(crate::simulation::pathfinding::compute_paths),
|
||||
);
|
||||
|
||||
tracing::debug!("NpcPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub struct Npc;
|
||||
|
||||
// 7 essential axes (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 1: Want (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum WantKind {
|
||||
Wealth,
|
||||
Safety,
|
||||
Knowledge,
|
||||
Connection,
|
||||
Power,
|
||||
Freedom,
|
||||
Justice,
|
||||
Revenge,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Want {
|
||||
pub primary: WantKind,
|
||||
pub intensity: u8, // 1-10, integer for determinism (D-010)
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 2: Secret / vulnerability (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SecretSeverity {
|
||||
Minor, // Social embarrassment
|
||||
Moderate, // Career-threatening
|
||||
Major, // Criminal / life-threatening
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Secret {
|
||||
pub description: String,
|
||||
pub severity: SecretSeverity,
|
||||
pub known_by: Vec<StableId>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 3: Relationships 1-3 (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum RelationshipKind {
|
||||
Colleague,
|
||||
Friend,
|
||||
Rival,
|
||||
Romantic,
|
||||
Family,
|
||||
Superior,
|
||||
Subordinate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelationshipEvent {
|
||||
pub tick: u64,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Relationship {
|
||||
pub target_id: StableId,
|
||||
pub kind: RelationshipKind,
|
||||
pub trust_level: i8, // -10..+10, integer for determinism (D-010)
|
||||
pub history: Vec<RelationshipEvent>,
|
||||
}
|
||||
|
||||
/// Per-NPC relationship slots. D-024: 3 key relationships for Active-tier.
|
||||
pub const MAX_KEY_RELATIONSHIPS: usize = 3;
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Relationships {
|
||||
pub entries: Vec<Relationship>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Relationship {
|
||||
/// Wire-format entity ID of the relationship target (scales to 10K+ NPCs)
|
||||
pub target_id: u64,
|
||||
pub kind: String,
|
||||
pub trust_level: f32,
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 4: Tolerance threshold (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToleranceThreshold {
|
||||
pub current_stress: f32,
|
||||
pub threshold: f32,
|
||||
pub current_stress: i16, // 0-100, integer for determinism (D-010)
|
||||
pub threshold: i16,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 5: Daily routine (D-024, D-031)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutineEntry {
|
||||
pub phase: DayPhase,
|
||||
pub location: TilePosition,
|
||||
pub activity: String,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DailyRoutine {
|
||||
pub entries: Vec<RoutineEntry>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl DailyRoutine {
|
||||
/// Get the routine entry for a given day phase.
|
||||
pub fn entry_for_phase(&self, phase: DayPhase) -> Option<&RoutineEntry> {
|
||||
self.entries.iter().find(|e| e.phase == phase)
|
||||
}
|
||||
|
||||
/// Get the expected location for a given day phase.
|
||||
pub fn expected_location(&self, phase: DayPhase) -> Option<TilePosition> {
|
||||
self.entry_for_phase(phase).map(|e| e.location)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 6: Information inventory (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownFact {
|
||||
pub fact_id: FactId,
|
||||
pub confidence: KnowledgeConfidence,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InformationInventory {
|
||||
pub known_facts: Vec<String>,
|
||||
pub facts: Vec<KnownFact>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 7: Contentment (D-024, Gore's thematic axis)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Contentment {
|
||||
pub level: f32,
|
||||
pub level: i16, // -100..+100, integer for determinism (D-010)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting axis 1: Personality traits (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum PersonalityTrait {
|
||||
Cautious,
|
||||
Bold,
|
||||
Honest,
|
||||
Deceptive,
|
||||
Compassionate,
|
||||
Ruthless,
|
||||
Curious,
|
||||
Incurious,
|
||||
Social,
|
||||
Reclusive,
|
||||
}
|
||||
|
||||
// 3 supporting axes (D-024)
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonalityTraits {
|
||||
pub traits: Vec<String>,
|
||||
pub traits: Vec<PersonalityTrait>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting axis 2: Tell system (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum TellTrigger {
|
||||
StressAboveThreshold,
|
||||
NearSpecificEntity(StableId),
|
||||
DuringActivity(String),
|
||||
TimeOfDay(DayPhase),
|
||||
Always,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tell {
|
||||
pub trigger: TellTrigger,
|
||||
pub behavior: String,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TellSystem {
|
||||
pub tells: Vec<String>,
|
||||
pub tells: Vec<Tell>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting axis 3: Skill set + combat component (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum Skill {
|
||||
Combat,
|
||||
Intimidation,
|
||||
Medical,
|
||||
Observation,
|
||||
Persuasion,
|
||||
Piloting,
|
||||
Stealth,
|
||||
Technical,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillSet {
|
||||
pub skills: Vec<String>,
|
||||
pub skills: BTreeMap<Skill, u8>, // Skill -> proficiency (1-10), BTreeMap for determinism
|
||||
pub combat_trained: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum CombatStyle {
|
||||
Ranged,
|
||||
Melee,
|
||||
Evasive,
|
||||
Defensive,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CombatCapability {
|
||||
pub weapon_proficiency: f32,
|
||||
pub combat_style: String,
|
||||
pub weapon_proficiency: u8, // 1-10
|
||||
pub combat_style: CombatStyle,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn daily_routine_entry_for_phase() {
|
||||
let routine = DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(5, 5, 0),
|
||||
activity: "Work".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: TilePosition::new(10, 10, 0),
|
||||
activity: "Bar".into(),
|
||||
},
|
||||
],
|
||||
description: "Test routine".into(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
routine.expected_location(DayPhase::Morning),
|
||||
Some(TilePosition::new(5, 5, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
routine.expected_location(DayPhase::Evening),
|
||||
Some(TilePosition::new(10, 10, 0))
|
||||
);
|
||||
assert_eq!(routine.expected_location(DayPhase::Afternoon), None);
|
||||
assert_eq!(routine.expected_location(DayPhase::Night), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_set_btreemap_deterministic() {
|
||||
let mut skills1 = BTreeMap::new();
|
||||
skills1.insert(Skill::Combat, 5);
|
||||
skills1.insert(Skill::Stealth, 3);
|
||||
skills1.insert(Skill::Persuasion, 7);
|
||||
|
||||
let mut skills2 = BTreeMap::new();
|
||||
skills2.insert(Skill::Persuasion, 7);
|
||||
skills2.insert(Skill::Combat, 5);
|
||||
skills2.insert(Skill::Stealth, 3);
|
||||
|
||||
// Insertion order doesn't matter — iteration is deterministic
|
||||
let keys1: Vec<_> = skills1.keys().collect();
|
||||
let keys2: Vec<_> = skills2.keys().collect();
|
||||
assert_eq!(keys1, keys2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_max_entries() {
|
||||
let rels = Relationships {
|
||||
entries: vec![
|
||||
Relationship {
|
||||
target_id: StableId(1),
|
||||
kind: RelationshipKind::Friend,
|
||||
trust_level: 5,
|
||||
history: vec![],
|
||||
},
|
||||
Relationship {
|
||||
target_id: StableId(2),
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust_level: 2,
|
||||
history: vec![],
|
||||
},
|
||||
Relationship {
|
||||
target_id: StableId(3),
|
||||
kind: RelationshipKind::Rival,
|
||||
trust_level: -3,
|
||||
history: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(rels.entries.len(), MAX_KEY_RELATIONSHIPS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Global relationship graph resource (D-024).
|
||||
//!
|
||||
//! Tracks how entities feel about each other. Separate from KnowledgeGraph
|
||||
//! (what entities know) — this is what entities feel.
|
||||
//! BTreeMap with tuple key (subject, target) for deterministic iteration
|
||||
//! and efficient prefix queries via range().
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
use super::{RelationshipEvent, RelationshipKind};
|
||||
|
||||
/// Edge in the relationship graph. Directed: A's feelings about B.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelationshipEdge {
|
||||
pub kind: RelationshipKind,
|
||||
pub trust: i8, // -10..+10, integer for determinism (D-010)
|
||||
pub history: Vec<RelationshipEvent>,
|
||||
pub last_interaction_tick: u64,
|
||||
}
|
||||
|
||||
/// Global relationship graph resource.
|
||||
/// BTreeMap<(subject, target), edge> for deterministic iteration (D-010).
|
||||
/// Directed graph: edge (A, B) represents how A feels about B.
|
||||
///
|
||||
/// TODO(v0.2): This is a global omniscient resource — all entities share one
|
||||
/// graph. This violates information boundaries (D-009/D-010) because any
|
||||
/// system can read any relationship. For multiplayer, this needs per-observer
|
||||
/// projection so each entity only sees relationships they should know about.
|
||||
/// Acceptable for v0.1 single-player where the server is authoritative.
|
||||
#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RelationshipGraph {
|
||||
edges: BTreeMap<(StableId, StableId), RelationshipEdge>,
|
||||
}
|
||||
|
||||
impl RelationshipGraph {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
edges: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set or update a relationship edge.
|
||||
pub fn set_relationship(
|
||||
&mut self,
|
||||
subject: StableId,
|
||||
target: StableId,
|
||||
edge: RelationshipEdge,
|
||||
) {
|
||||
self.edges.insert((subject, target), edge);
|
||||
}
|
||||
|
||||
/// Get a relationship edge (how subject feels about target).
|
||||
pub fn get_relationship(
|
||||
&self,
|
||||
subject: &StableId,
|
||||
target: &StableId,
|
||||
) -> Option<&RelationshipEdge> {
|
||||
self.edges.get(&(*subject, *target))
|
||||
}
|
||||
|
||||
/// Get all relationships for a subject (who the subject has feelings about).
|
||||
/// Uses BTreeMap range query: all edges with matching subject are contiguous.
|
||||
pub fn relationships_of(&self, subject: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||
self.edges
|
||||
.range((*subject, StableId(0))..=(*subject, StableId(u64::MAX)))
|
||||
.map(|((_, target), edge)| (target, edge))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all entities who have feelings about a target.
|
||||
/// Full scan — use for event detection, not per-tick queries.
|
||||
pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|((_, t), _)| t == target)
|
||||
.map(|((s, _), edge)| (s, edge))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Update trust level for an existing relationship.
|
||||
/// Clamps to -10..+10. Returns false if edge does not exist.
|
||||
pub fn adjust_trust(&mut self, subject: &StableId, target: &StableId, delta: i8) -> bool {
|
||||
if let Some(edge) = self.edges.get_mut(&(*subject, *target)) {
|
||||
edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of edges in the graph.
|
||||
pub fn edge_count(&self) -> usize {
|
||||
self.edges.len()
|
||||
}
|
||||
|
||||
/// Whether the graph has any edges.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.edges.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_edge(kind: RelationshipKind, trust: i8) -> RelationshipEdge {
|
||||
RelationshipEdge {
|
||||
kind,
|
||||
trust,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_relationship() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
let a = StableId(1);
|
||||
let b = StableId(2);
|
||||
|
||||
graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 5));
|
||||
|
||||
let edge = graph.get_relationship(&a, &b).unwrap();
|
||||
assert_eq!(edge.kind, RelationshipKind::Friend);
|
||||
assert_eq!(edge.trust, 5);
|
||||
|
||||
// Reverse direction is empty
|
||||
assert!(graph.get_relationship(&b, &a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_of_subject() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
let a = StableId(1);
|
||||
|
||||
graph.set_relationship(a, StableId(10), make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(a, StableId(20), make_edge(RelationshipKind::Colleague, 2));
|
||||
graph.set_relationship(a, StableId(30), make_edge(RelationshipKind::Rival, -3));
|
||||
// Different subject — should not appear
|
||||
graph.set_relationship(StableId(2), StableId(10), make_edge(RelationshipKind::Family, 8));
|
||||
|
||||
let rels = graph.relationships_of(&a);
|
||||
assert_eq!(rels.len(), 3);
|
||||
// BTreeMap iteration order: sorted by target StableId
|
||||
assert_eq!(*rels[0].0, StableId(10));
|
||||
assert_eq!(*rels[1].0, StableId(20));
|
||||
assert_eq!(*rels[2].0, StableId(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn who_knows_target() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
let target = StableId(10);
|
||||
|
||||
graph.set_relationship(StableId(1), target, make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(StableId(2), target, make_edge(RelationshipKind::Rival, -2));
|
||||
graph.set_relationship(StableId(3), target, make_edge(RelationshipKind::Colleague, 0));
|
||||
// Edge to different target — should not appear
|
||||
graph.set_relationship(StableId(1), StableId(99), make_edge(RelationshipKind::Family, 8));
|
||||
|
||||
let knowers = graph.who_knows(&target);
|
||||
assert_eq!(knowers.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_trust_clamps() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
let a = StableId(1);
|
||||
let b = StableId(2);
|
||||
graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 8));
|
||||
|
||||
// Positive overflow clamps at +10
|
||||
assert!(graph.adjust_trust(&a, &b, 5));
|
||||
assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, 10);
|
||||
|
||||
// Negative underflow clamps at -10
|
||||
assert!(graph.adjust_trust(&a, &b, -25));
|
||||
assert_eq!(graph.get_relationship(&a, &b).unwrap().trust, -10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_trust_missing_edge_returns_false() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
assert!(!graph.adjust_trust(&StableId(1), &StableId(2), 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graph() {
|
||||
let graph = RelationshipGraph::new();
|
||||
assert!(graph.is_empty());
|
||||
assert_eq!(graph.edge_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_iteration() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
// Insert in arbitrary order
|
||||
graph.set_relationship(StableId(3), StableId(1), make_edge(RelationshipKind::Rival, -1));
|
||||
graph.set_relationship(StableId(1), StableId(2), make_edge(RelationshipKind::Friend, 5));
|
||||
graph.set_relationship(StableId(2), StableId(3), make_edge(RelationshipKind::Colleague, 0));
|
||||
|
||||
// Iteration order should be deterministic (sorted by (subject, target))
|
||||
let keys: Vec<_> = graph.edges.keys().collect();
|
||||
assert_eq!(*keys[0], (StableId(1), StableId(2)));
|
||||
assert_eq!(*keys[1], (StableId(2), StableId(3)));
|
||||
assert_eq!(*keys[2], (StableId(3), StableId(1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Daily routine system (#88).
|
||||
//!
|
||||
//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs
|
||||
//! whose DailyRoutine has a location for the new phase.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::npc::{DailyRoutine, Npc};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::pathfinding::PathRequest;
|
||||
use crate::simulation::time::{DayPhase, SimulationTime};
|
||||
|
||||
/// Resource tracking the previous day phase for transition detection.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct PreviousDayPhase {
|
||||
pub phase: DayPhase,
|
||||
pub day: u64,
|
||||
}
|
||||
|
||||
impl Default for PreviousDayPhase {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: DayPhase::Morning,
|
||||
day: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: detect day-phase transitions and issue PathRequests for NPC routines.
|
||||
/// Runs after advance_tick so the current phase is up-to-date.
|
||||
pub fn check_phase_transition(
|
||||
time: Res<SimulationTime>,
|
||||
mut previous: ResMut<PreviousDayPhase>,
|
||||
mut commands: Commands,
|
||||
npcs: Query<(Entity, &TilePosition, &DailyRoutine), With<Npc>>,
|
||||
) {
|
||||
let current_phase = time.day_phase();
|
||||
let current_day = time.day();
|
||||
|
||||
if current_phase == previous.phase && current_day == previous.day {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Day phase transition: {:?} -> {:?} (day {} -> {})",
|
||||
previous.phase,
|
||||
current_phase,
|
||||
previous.day,
|
||||
current_day
|
||||
);
|
||||
|
||||
previous.phase = current_phase;
|
||||
previous.day = current_day;
|
||||
|
||||
for (entity, current_pos, routine) in npcs.iter() {
|
||||
if let Some(expected_location) = routine.expected_location(current_phase) {
|
||||
if *current_pos != expected_location {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(PathRequest {
|
||||
goal: expected_location,
|
||||
});
|
||||
tracing::trace!(
|
||||
"Entity {:?}: routine path request to {:?} for {:?}",
|
||||
entity,
|
||||
expected_location,
|
||||
current_phase
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::RoutineEntry;
|
||||
use crate::simulation::time::{MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
fn setup_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<PreviousDayPhase>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_transition_generates_path_request() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let afternoon_loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0), // Not at afternoon location
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: afternoon_loc,
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Advance time to Afternoon boundary
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let request = world.get::<PathRequest>(entity).unwrap();
|
||||
assert_eq!(request.goal, afternoon_loc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_transition_no_request() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(10, 10, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Time still at Morning (tick 0), same as PreviousDayPhase default
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.get::<PathRequest>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_already_at_destination_no_request() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
loc, // Already at afternoon location
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: loc,
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.get::<PathRequest>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_without_routine_entry_ignored() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 5, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(5, 5, 0),
|
||||
activity: "Sleep".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Transition to Afternoon, but NPC only has Morning entry
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.get::<PathRequest>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_rollover_triggers_morning_routine() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Start at Night
|
||||
let night_tick = 1080 * TICKS_PER_GAME_MINUTE;
|
||||
world.resource_mut::<SimulationTime>().tick = night_tick;
|
||||
world.resource_mut::<PreviousDayPhase>().phase = DayPhase::Night;
|
||||
world.resource_mut::<PreviousDayPhase>().day = 0;
|
||||
|
||||
let morning_loc = TilePosition::new(3, 3, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(20, 20, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: morning_loc,
|
||||
activity: "Wake up".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Advance to next day's Morning (day 1, tick 0 of new day)
|
||||
world.resource_mut::<SimulationTime>().tick = 1440 * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let request = world.get::<PathRequest>(entity).unwrap();
|
||||
assert_eq!(request.goal, morning_loc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Observation event generator (#239).
|
||||
//!
|
||||
//! Interprets what the observer sees (and doesn't see) against known NPC
|
||||
//! routines and knowledge graph state. Produces high-level observation events
|
||||
//! that drive monologue and investigation triggers.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::{DailyRoutine, Npc};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// What triggered an observation event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ObservationTrigger {
|
||||
/// NPC is visible but not at their expected routine location.
|
||||
RoutineDeviation {
|
||||
npc: StableId,
|
||||
expected: TilePosition,
|
||||
actual: TilePosition,
|
||||
},
|
||||
/// Known NPC's expected routine location is visible, but the NPC is not there.
|
||||
Absence {
|
||||
npc: StableId,
|
||||
expected: TilePosition,
|
||||
},
|
||||
/// An entity visible in LOS that the observer has no prior knowledge of.
|
||||
NewEntity {
|
||||
entity: StableId,
|
||||
location: TilePosition,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single observation event produced by the interpretation system.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObservationEvent {
|
||||
pub tick: u64,
|
||||
pub trigger: ObservationTrigger,
|
||||
pub observer: Entity,
|
||||
}
|
||||
|
||||
/// Resource: queue of observation events for downstream systems (monologue, UI).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ObservationEventQueue {
|
||||
events: Vec<ObservationEvent>,
|
||||
}
|
||||
|
||||
impl ObservationEventQueue {
|
||||
pub fn push(&mut self, event: ObservationEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<ObservationEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// System: interpret visible snapshot against known routines and knowledge.
|
||||
///
|
||||
/// Runs BEFORE knowledge events are processed so it can detect new entities
|
||||
/// by comparing visible NPCs against the previous tick's knowledge state.
|
||||
/// Produces observation events for: routine deviations, absences, new entities.
|
||||
pub fn generate_observation_events(
|
||||
time: Res<SimulationTime>,
|
||||
buffer: Res<SnapshotBuffer>,
|
||||
registry: Res<EntityRegistry>,
|
||||
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
npc_query: Query<(&TilePosition, &DailyRoutine), With<Npc>>,
|
||||
mut event_queue: ResMut<ObservationEventQueue>,
|
||||
) {
|
||||
let Some(snapshot) = &buffer.snapshot else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((observer_entity, observer_kg)) = observer_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let current_phase = time.day_phase();
|
||||
|
||||
// Build set of visible tile positions for absence checks
|
||||
let visible_tile_set: std::collections::HashSet<(i32, i32, i32)> = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.map(|t| (t.x, t.y, t.z))
|
||||
.collect();
|
||||
|
||||
// Collect visible NPC entity bits for absence checks
|
||||
let visible_npc_bits: std::collections::HashSet<u64> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
// --- Routine deviation + New entity detection ---
|
||||
for visible in &snapshot.entities {
|
||||
if matches!(visible.kind, EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entity = Entity::from_bits(visible.entity_id);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Absence detection ---
|
||||
// For each known NPC not in visible set, check if their expected routine
|
||||
// location IS in our visible tiles (meaning we can see the spot but
|
||||
// the NPC isn't there).
|
||||
for (stable_id, _knowledge) in observer_kg.known_entities_iter() {
|
||||
let Some(entity) = registry.to_entity(stable_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Skip if currently visible
|
||||
if visible_npc_bits.contains(&entity.to_bits()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this NPC has a routine with an expected location
|
||||
if let Ok((_pos, routine)) = npc_query.get(entity) {
|
||||
if let Some(expected_pos) = routine.expected_location(current_phase) {
|
||||
// If we can see the expected location but the NPC isn't there
|
||||
if visible_tile_set.contains(&(expected_pos.x, expected_pos.y, expected_pos.z)) {
|
||||
event_queue.push(ObservationEvent {
|
||||
tick: time.tick,
|
||||
trigger: ObservationTrigger::Absence {
|
||||
npc: *stable_id,
|
||||
expected: expected_pos,
|
||||
},
|
||||
observer: observer_entity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::npc::RoutineEntry;
|
||||
use crate::perception::observer::compute_observer_snapshot;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::movement::WalkabilityMap;
|
||||
use crate::simulation::time::{DayPhase, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ObservationEventQueue>();
|
||||
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.
|
||||
fn run_pipeline(world: &mut World) {
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((
|
||||
compute_observer_snapshot,
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(compute_observer_snapshot),
|
||||
generate_observation_events
|
||||
.after(crate::perception::observation::emit_observation_events),
|
||||
crate::knowledge::events::process_knowledge_events
|
||||
.after(generate_observation_events),
|
||||
));
|
||||
schedule.run(world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_deviation_detected() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Set time to Afternoon
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC at (16,14) but routine says they should be at (20,10) in Afternoon
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 10, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let deviations: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
&e.trigger,
|
||||
ObservationTrigger::RoutineDeviation { npc, .. } if *npc == npc_sid
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(deviations.len(), 1, "should detect routine deviation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_deviation_at_correct_location() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Time at Morning (tick 0, default)
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC at (16,14) and routine says Morning at (16,14)
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 14, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 14, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let deviations: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(&e.trigger, ObservationTrigger::RoutineDeviation { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
deviations.is_empty(),
|
||||
"no deviation when NPC is at expected location"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absence_when_location_visible() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC behind player (blind spot, not visible) but routine says
|
||||
// Morning at (16,15) which IS in the player's forward view
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(16, 30, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 15, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows about the NPC (has observed before)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 0);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 1);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let absences: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
&e.trigger,
|
||||
ObservationTrigger::Absence { npc, .. } if *npc == npc_sid
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(absences.len(), 1, "should detect absence at visible location");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_entity_detected() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(), // Empty — never seen anyone
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let new_entities: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
&e.trigger,
|
||||
ObservationTrigger::NewEntity { entity, .. } if *entity == npc_sid
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(new_entities.len(), 1, "should detect new entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_entity_no_new_event() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player already knows about the NPC
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
run_pipeline(&mut world);
|
||||
|
||||
let queue = world.resource::<ObservationEventQueue>();
|
||||
let new_entities: Vec<_> = queue
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(&e.trigger, ObservationTrigger::NewEntity { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
new_entities.is_empty(),
|
||||
"should not emit NewEntity for known entity"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// Generates ObserverSnapshot for client rendering
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod interpretation;
|
||||
pub mod observation;
|
||||
pub mod observer;
|
||||
pub mod shadowcast;
|
||||
@@ -14,8 +16,14 @@ pub mod vision_cone;
|
||||
pub struct PerceptionPlugin;
|
||||
|
||||
impl Plugin for PerceptionPlugin {
|
||||
fn build(&self, _app: &mut App) {
|
||||
// Stub implementation - will be populated in phase 2
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<interpretation::ObservationEventQueue>()
|
||||
.add_systems(
|
||||
Update,
|
||||
interpretation::generate_observation_events
|
||||
.after(observation::emit_observation_events)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
);
|
||||
tracing::debug!("PerceptionPlugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,4 +701,71 @@ mod tests {
|
||||
"entity without last_known_position should not appear as ghost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_npcs_in_los_all_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Three NPCs in front of player, no walls
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Player + 3 NPCs = 4 entities
|
||||
assert_eq!(snapshot.entities.len(), 4);
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert_eq!(npcs.len(), 3);
|
||||
assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
|
||||
let mut world = setup_world(32, 32);
|
||||
// Wall at (16,14)
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC 1: behind wall (should be hidden)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
|
||||
// NPC 2: to the side, no wall (should be visible)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
// NPC 3: also visible
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Player + 2 visible NPCs = 3 (NPC behind wall excluded)
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,11 +286,9 @@ fn has_line_of_sight(
|
||||
|
||||
loop {
|
||||
// Check if we hit a blocking tile BEFORE reaching target
|
||||
if (x != x0 || y != y0) && (x != x1 || y != y1) {
|
||||
if is_opaque(x, y) {
|
||||
// Hit an obstacle before reaching target - blocked
|
||||
return false;
|
||||
}
|
||||
if (x != x0 || y != y0) && (x != x1 || y != y1) && is_opaque(x, y) {
|
||||
// Hit an obstacle before reaching target - blocked
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we reach the target, we can see it
|
||||
|
||||
@@ -6,6 +6,8 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod input;
|
||||
pub mod movement;
|
||||
pub mod path_follow;
|
||||
pub mod pathfinding;
|
||||
pub mod rng;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
@@ -24,8 +26,11 @@ impl Plugin for SimulationPlugin {
|
||||
Update,
|
||||
(
|
||||
input::process_player_input,
|
||||
movement::validate_movement.after(input::process_player_input),
|
||||
time::advance_tick.after(movement::validate_movement),
|
||||
pathfinding::compute_paths.after(input::process_player_input),
|
||||
path_follow::follow_paths.after(pathfinding::compute_paths),
|
||||
movement::validate_movement.after(path_follow::follow_paths),
|
||||
path_follow::cleanup_path_blocked.after(movement::validate_movement),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! NPC path following system (#238).
|
||||
//!
|
||||
//! Per-tick NPC position updates along computed paths.
|
||||
//! Separate from pathfinding — this is the movement execution system.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::MoveIntent;
|
||||
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
|
||||
|
||||
/// Movement speed component. Controls ticks between path steps.
|
||||
/// Default: 1 step per tick. Higher values = slower movement.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct MovementSpeed {
|
||||
pub ticks_per_step: u32,
|
||||
ticks_since_last_step: u32,
|
||||
}
|
||||
|
||||
impl Default for MovementSpeed {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ticks_per_step: 1,
|
||||
ticks_since_last_step: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MovementSpeed {
|
||||
pub fn new(ticks_per_step: u32) -> Self {
|
||||
Self {
|
||||
ticks_per_step: ticks_per_step.max(1),
|
||||
ticks_since_last_step: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if entity should step this tick.
|
||||
fn should_step(&mut self) -> bool {
|
||||
self.ticks_since_last_step += 1;
|
||||
if self.ticks_since_last_step >= self.ticks_per_step {
|
||||
self.ticks_since_last_step = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: NPC entities with ComputedPath advance along their path.
|
||||
/// Creates MoveIntent for the next step. Removes ComputedPath when complete.
|
||||
pub fn follow_paths(
|
||||
mut commands: Commands,
|
||||
mut query: Query<(Entity, &mut ComputedPath, Option<&mut MovementSpeed>), With<Npc>>,
|
||||
) {
|
||||
for (entity, mut path, speed_opt) in query.iter_mut() {
|
||||
if let Some(mut speed) = speed_opt {
|
||||
if !speed.should_step() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(next_pos) = path.next_step() {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(MoveIntent { target: *next_pos });
|
||||
path.advance();
|
||||
}
|
||||
|
||||
if path.is_complete() {
|
||||
commands.entity(entity).remove::<ComputedPath>();
|
||||
tracing::trace!("Entity {:?}: path complete", entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: clean up PathBlocked markers after one tick.
|
||||
pub fn cleanup_path_blocked(mut commands: Commands, query: Query<Entity, With<PathBlocked>>) {
|
||||
for entity in query.iter() {
|
||||
commands.entity(entity).remove::<PathBlocked>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::pathfinding::ComputedPath;
|
||||
|
||||
#[test]
|
||||
fn npc_follows_path_one_step() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(0, 0, 0),
|
||||
ComputedPath {
|
||||
steps: vec![
|
||||
TilePosition::new(1, 0, 0),
|
||||
TilePosition::new(2, 0, 0),
|
||||
TilePosition::new(3, 0, 0),
|
||||
],
|
||||
current_index: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(follow_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// MoveIntent should target step 0
|
||||
let intent = world.get::<MoveIntent>(entity).unwrap();
|
||||
assert_eq!(intent.target, TilePosition::new(1, 0, 0));
|
||||
// Path advanced to index 1
|
||||
let path = world.get::<ComputedPath>(entity).unwrap();
|
||||
assert_eq!(path.current_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_path_complete_removes_component() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(2, 0, 0),
|
||||
ComputedPath {
|
||||
steps: vec![TilePosition::new(3, 0, 0)],
|
||||
current_index: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(follow_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// After consuming the last step, ComputedPath should be removed
|
||||
assert!(world.get::<ComputedPath>(entity).is_none());
|
||||
// But MoveIntent was still created
|
||||
assert!(world.get::<MoveIntent>(entity).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_speed_throttles() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(0, 0, 0),
|
||||
ComputedPath {
|
||||
steps: vec![
|
||||
TilePosition::new(1, 0, 0),
|
||||
TilePosition::new(2, 0, 0),
|
||||
],
|
||||
current_index: 0,
|
||||
},
|
||||
MovementSpeed::new(3),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(follow_paths);
|
||||
|
||||
// Tick 1: no step (1/3)
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
|
||||
// Tick 2: no step (2/3)
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
|
||||
// Tick 3: step! (3/3)
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(entity).is_some());
|
||||
assert_eq!(
|
||||
world.get::<MoveIntent>(entity).unwrap().target,
|
||||
TilePosition::new(1, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_npc_entity_ignored() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
|
||||
// Entity without Npc marker
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(0, 0, 0),
|
||||
ComputedPath {
|
||||
steps: vec![TilePosition::new(1, 0, 0)],
|
||||
current_index: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(follow_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Should NOT have MoveIntent since it's not an Npc
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
// Path unchanged
|
||||
assert_eq!(world.get::<ComputedPath>(entity).unwrap().current_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_path_blocked_removes_marker() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
|
||||
let entity = world.spawn((Npc, PathBlocked)).id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(cleanup_path_blocked);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.get::<PathBlocked>(entity).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Tile-based A* pathfinding (#237).
|
||||
//!
|
||||
//! Computes paths over the WalkabilityMap using the `pathfinding` crate.
|
||||
//! NPCs request paths via PathRequest component; the compute_paths system
|
||||
//! resolves them into ComputedPath (success) or PathBlocked (no route).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::simulation::movement::{TilePosition, WalkabilityMap};
|
||||
|
||||
/// Component requesting a path from current position to a goal.
|
||||
/// Consumed by the compute_paths system each tick.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PathRequest {
|
||||
pub goal: TilePosition,
|
||||
}
|
||||
|
||||
/// Component holding a computed path.
|
||||
/// Steps run from start (exclusive) to goal (inclusive).
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComputedPath {
|
||||
pub steps: Vec<TilePosition>,
|
||||
pub current_index: usize,
|
||||
}
|
||||
|
||||
impl ComputedPath {
|
||||
/// Get the next step in the path, or None if finished.
|
||||
pub fn next_step(&self) -> Option<&TilePosition> {
|
||||
self.steps.get(self.current_index)
|
||||
}
|
||||
|
||||
/// Advance to the next step. Returns true if there are more steps.
|
||||
pub fn advance(&mut self) -> bool {
|
||||
if self.current_index < self.steps.len() {
|
||||
self.current_index += 1;
|
||||
}
|
||||
self.current_index < self.steps.len()
|
||||
}
|
||||
|
||||
/// Whether the path has been fully traversed.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.current_index >= self.steps.len()
|
||||
}
|
||||
|
||||
/// Remaining steps count.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.steps.len().saturating_sub(self.current_index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker component: pathfinding failed, no route exists.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PathBlocked;
|
||||
|
||||
/// System: compute paths for entities with PathRequest components.
|
||||
/// Uses A* over the WalkabilityMap with cardinal movement (4 neighbors).
|
||||
/// Cardinal-only is a deliberate v0.1 simplification: diagonal movement
|
||||
/// would require √2 cost handling and diagonal wall-clipping checks.
|
||||
/// Removes PathRequest and inserts ComputedPath or PathBlocked.
|
||||
pub fn compute_paths(
|
||||
mut commands: Commands,
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
queries: Query<(Entity, &TilePosition, &PathRequest)>,
|
||||
) {
|
||||
let Some(walkability) = walkability else {
|
||||
// No map loaded — consume requests and mark blocked
|
||||
for (entity, _, _) in queries.iter() {
|
||||
commands.entity(entity).remove::<PathRequest>();
|
||||
commands.entity(entity).insert(PathBlocked);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
for (entity, current_pos, request) in queries.iter() {
|
||||
commands.entity(entity).remove::<PathRequest>();
|
||||
|
||||
if *current_pos == request.goal {
|
||||
commands.entity(entity).insert(ComputedPath {
|
||||
steps: Vec::new(),
|
||||
current_index: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let goal = request.goal;
|
||||
let result = pathfinding::directed::astar::astar(
|
||||
current_pos,
|
||||
|pos| {
|
||||
pos.cardinal_neighbors()
|
||||
.into_iter()
|
||||
.filter(|neighbor| walkability.can_move_to(neighbor))
|
||||
.map(|neighbor| (neighbor, 1u32))
|
||||
},
|
||||
// manhattan_distance returns None for cross-z-level pairs;
|
||||
// u32::MAX makes A* deprioritize those nodes (v0.1: single z-level)
|
||||
|pos| pos.manhattan_distance(&goal).unwrap_or(u32::MAX),
|
||||
|pos| *pos == goal,
|
||||
);
|
||||
|
||||
match result {
|
||||
Some((path, _cost)) => {
|
||||
// path includes start position; skip it
|
||||
let steps: Vec<TilePosition> = path.into_iter().skip(1).collect();
|
||||
tracing::trace!("Entity {:?}: path to {:?}, {} steps", entity, goal, steps.len());
|
||||
commands.entity(entity).insert(ComputedPath {
|
||||
steps,
|
||||
current_index: 0,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
tracing::trace!("Entity {:?}: no path to {:?}", entity, goal);
|
||||
commands.entity(entity).insert(PathBlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn path_to_adjacent_tile() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.get::<PathRequest>(entity).is_none());
|
||||
let path = world.get::<ComputedPath>(entity).unwrap();
|
||||
assert_eq!(path.steps, vec![TilePosition::new(5, 4, 0)]);
|
||||
assert_eq!(path.current_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_around_wall() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
// Wall at (5,4) blocks direct north
|
||||
map.set_walkable(&TilePosition::new(5, 4, 0), false);
|
||||
world.insert_resource(map);
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(5, 3, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let path = world.get::<ComputedPath>(entity).unwrap();
|
||||
assert!(!path.steps.is_empty());
|
||||
// Path should end at goal
|
||||
assert_eq!(*path.steps.last().unwrap(), TilePosition::new(5, 3, 0));
|
||||
// Path should not go through the wall
|
||||
assert!(!path.steps.contains(&TilePosition::new(5, 4, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_to_same_position() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(5, 5, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let path = world.get::<ComputedPath>(entity).unwrap();
|
||||
assert!(path.steps.is_empty());
|
||||
assert!(path.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_blocked_no_route() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
// Surround goal with walls
|
||||
let goal = TilePosition::new(5, 3, 0);
|
||||
for neighbor in goal.cardinal_neighbors() {
|
||||
map.set_walkable(&neighbor, false);
|
||||
}
|
||||
world.insert_resource(map);
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
PathRequest { goal },
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(world.get::<PathRequest>(entity).is_none());
|
||||
assert!(world.get::<ComputedPath>(entity).is_none());
|
||||
assert!(world.get::<PathBlocked>(entity).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_path_navigation() {
|
||||
let mut path = ComputedPath {
|
||||
steps: vec![
|
||||
TilePosition::new(1, 0, 0),
|
||||
TilePosition::new(2, 0, 0),
|
||||
TilePosition::new(3, 0, 0),
|
||||
],
|
||||
current_index: 0,
|
||||
};
|
||||
|
||||
assert_eq!(path.remaining(), 3);
|
||||
assert!(!path.is_complete());
|
||||
|
||||
assert_eq!(*path.next_step().unwrap(), TilePosition::new(1, 0, 0));
|
||||
assert!(path.advance()); // -> index 1
|
||||
assert_eq!(*path.next_step().unwrap(), TilePosition::new(2, 0, 0));
|
||||
assert!(path.advance()); // -> index 2
|
||||
assert_eq!(*path.next_step().unwrap(), TilePosition::new(3, 0, 0));
|
||||
assert!(!path.advance()); // -> index 3, no more steps
|
||||
assert!(path.is_complete());
|
||||
assert!(path.next_step().is_none());
|
||||
assert_eq!(path.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_deterministic() {
|
||||
let map = {
|
||||
let mut m = WalkabilityMap::new(20, 20, 1);
|
||||
// Add some walls to make routing interesting
|
||||
for y in 3..8 {
|
||||
m.set_walkable(&TilePosition::new(5, y, 0), false);
|
||||
}
|
||||
m
|
||||
};
|
||||
|
||||
// Run pathfinding twice with same setup
|
||||
let mut results = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(map.clone());
|
||||
world.spawn((
|
||||
TilePosition::new(4, 5, 0),
|
||||
PathRequest {
|
||||
goal: TilePosition::new(6, 5, 0),
|
||||
},
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_paths);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut paths: Vec<_> = world
|
||||
.query::<&ComputedPath>()
|
||||
.iter(&world)
|
||||
.map(|p| p.steps.clone())
|
||||
.collect();
|
||||
results.push(paths.pop().unwrap());
|
||||
}
|
||||
|
||||
assert_eq!(results[0], results[1], "pathfinding must be deterministic");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user