Compare commits
@@ -0,0 +1,16 @@
|
|||||||
|
# Git Safety
|
||||||
|
|
||||||
|
## Staging rules
|
||||||
|
|
||||||
|
- **Stage files by name** — never use `git add -A` or `git add .`
|
||||||
|
- Verify no secrets, saves, or binary blobs are staged
|
||||||
|
- Skip files in `.gitignore`
|
||||||
|
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||||
|
|
||||||
|
## Commit conventions
|
||||||
|
|
||||||
|
Use conventional commits: `<type>(<scope>): <summary>`
|
||||||
|
|
||||||
|
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||||
|
|
||||||
|
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Local Services
|
||||||
|
|
||||||
|
Endpoints are also preconfigured in `db/connectors/config.json`.
|
||||||
|
|
||||||
|
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||||
|
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||||
|
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||||
|
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Project Structure (detailed)
|
||||||
|
|
||||||
|
```
|
||||||
|
client/ # Godot 4 client
|
||||||
|
server/ # Rust/bevy_ecs simulation server
|
||||||
|
tooling/ # Build tools, scripts, asset pipelines
|
||||||
|
tests/ # Integration and end-to-end tests
|
||||||
|
.config/ # Configuration files (linters, formatters, CI)
|
||||||
|
.cache/ # Local caches for testing/linting (gitignored)
|
||||||
|
docs/
|
||||||
|
discussions/ # Discussion rounds (archived here when complete)
|
||||||
|
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||||
|
architecture/ # Technical architecture documents
|
||||||
|
design/ # Game design documents
|
||||||
|
diagrams/ # d2 source + PNG renders
|
||||||
|
sprints/ # Sprint briefings per team
|
||||||
|
workshops/ # Workshop briefs and outputs
|
||||||
|
db/
|
||||||
|
schema.sql # Database schema
|
||||||
|
connectors/ # Connector scripts for SQLite and Qdrant
|
||||||
|
config.json # Endpoint configuration
|
||||||
|
ticket # Ticket CLI
|
||||||
|
sqlite_connector.py # SQLite mini MCP
|
||||||
|
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||||
|
.claude/
|
||||||
|
agents/ # Agent personality files
|
||||||
|
skills/ # Skill definitions
|
||||||
|
rules/ # Auto-loaded instruction modules
|
||||||
|
decisions/ # Decision domain files (source of truth)
|
||||||
|
README.md # Domain index — use this to find specific D-records
|
||||||
|
architecture.md # Architecture decisions
|
||||||
|
perception.md # Perception and information system decisions
|
||||||
|
content.md # Content and narrative decisions
|
||||||
|
scope.md # Scope and feature decisions
|
||||||
|
process.md # Process and workflow decisions
|
||||||
|
questions.md # Open questions (Q-NNN)
|
||||||
|
rejected.md # Rejected proposals (R-NNN)
|
||||||
|
DECISIONS.md # Redirect to decisions/ directory
|
||||||
|
TEAM.md # Team roster and roles
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Gitea Access (tea CLI)
|
||||||
|
|
||||||
|
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||||
|
|
||||||
|
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List open PRs
|
||||||
|
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||||
|
|
||||||
|
# View a PR with comments
|
||||||
|
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||||
|
|
||||||
|
# Post a comment on a PR (or issue)
|
||||||
|
tooling/tea-comment <NUMBER> "comment body"
|
||||||
|
|
||||||
|
# Approve a PR
|
||||||
|
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||||
|
|
||||||
|
# List issues
|
||||||
|
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key rules
|
||||||
|
|
||||||
|
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||||
|
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||||
|
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
|
||||||
|
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||||
|
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||||
|
|
||||||
|
Always provide all required flags to ensure non-interactive execution:
|
||||||
|
```bash
|
||||||
|
tea pr create \
|
||||||
|
--repo jpmschweitzer/settled-reach \
|
||||||
|
--login schweitz \
|
||||||
|
--title "feat(scope): short description" \
|
||||||
|
--description "PR body here" \
|
||||||
|
--base main \
|
||||||
|
--head branch-name
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Team Patterns
|
||||||
|
|
||||||
|
## Model selection
|
||||||
|
|
||||||
|
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||||
|
sprint planning, large reviews), switch to extended context on-demand:
|
||||||
|
|
||||||
|
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||||
|
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||||
|
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
|
||||||
|
|
||||||
|
## Large content pushes
|
||||||
|
|
||||||
|
When producing many files (wiki pages, content batches, bulk docs):
|
||||||
|
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||||
|
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||||
|
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||||
|
|
||||||
|
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||||
|
|
||||||
|
## Team monitoring (stuck agent detection)
|
||||||
|
|
||||||
|
When leading a team (sprint, workshop, or any multi-agent session):
|
||||||
|
|
||||||
|
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||||
|
> If you have been working on a single task for more than 15 minutes
|
||||||
|
> without making progress, message the team lead with what is blocking
|
||||||
|
> you. Do not keep retrying the same approach silently.
|
||||||
|
|
||||||
|
**Team lead proactive checks:**
|
||||||
|
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
|
||||||
|
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
|
||||||
|
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"Bash(make)",
|
"Bash(make)",
|
||||||
|
|
||||||
"Bash(tea *)",
|
"Bash(tea *)",
|
||||||
|
"Bash(tooling/tea-comment *)",
|
||||||
|
|
||||||
"Bash(chmod *)",
|
"Bash(chmod *)",
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
name: bug-report
|
||||||
|
description: >
|
||||||
|
Process in-game bug reports captured by the Godot client's bug reporter.
|
||||||
|
Use when the user says "bug reports", "check bug reports", "process bugs",
|
||||||
|
or invokes /bug-report. Scans the user:// bug-reports directory, summarizes
|
||||||
|
each report, and offers investigation, ticket creation, or dismissal.
|
||||||
|
user-invocable: true
|
||||||
|
allowed-tools: Bash, Read, Grep, Glob, Write
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug Report Skill
|
||||||
|
|
||||||
|
Process in-game bug reports exported by the Godot client to the user data
|
||||||
|
directory. Each report is a directory containing a snapshot of game state at the
|
||||||
|
moment the tester filed the report.
|
||||||
|
|
||||||
|
```
|
||||||
|
BUG_REPORT_DIR: /var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report structure
|
||||||
|
|
||||||
|
Each report lives in a directory named `gauntlet-t{tick}-{timestamp}/` and
|
||||||
|
contains these files:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `description.txt` | Tester notes + metadata (tick, room, stance, facing, position) |
|
||||||
|
| `render.txt` | Simplified text render of the game snapshot |
|
||||||
|
| `snapshot.json` | Full JSON snapshot (entities, dialogue state, etc.) |
|
||||||
|
| `inputs.jsonl` | Last 60 ticks of player input (replay format) |
|
||||||
|
| `snapshots.jsonl` | Last 60 ticks of observer snapshots |
|
||||||
|
| `seed.txt` | RNG seed for deterministic replay |
|
||||||
|
|
||||||
|
## Invocation
|
||||||
|
|
||||||
|
- `/bug-report` — scan and process all unprocessed reports
|
||||||
|
- `/bug-report <directory-name>` — process a specific report by directory name
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Scan for unprocessed reports
|
||||||
|
|
||||||
|
List all report directories in the bug reports directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -1d "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/"*/
|
||||||
|
```
|
||||||
|
|
||||||
|
If no directories are found, report "No bug reports found." and stop.
|
||||||
|
|
||||||
|
If the user provided a specific directory name as argument, filter to only that
|
||||||
|
directory. If it does not exist, report the error and list available reports.
|
||||||
|
|
||||||
|
### 2. Read and summarize each report
|
||||||
|
|
||||||
|
For each report directory, read the following files using the Read tool:
|
||||||
|
|
||||||
|
1. **`description.txt`** — extract:
|
||||||
|
- Tester description / notes (free text at top)
|
||||||
|
- Tick number
|
||||||
|
- Room name
|
||||||
|
- Player stance, facing, position
|
||||||
|
2. **`render.txt`** — extract:
|
||||||
|
- A brief description of what the text render shows (room layout, visible
|
||||||
|
entities, player position marker)
|
||||||
|
3. **`snapshot.json`** — extract:
|
||||||
|
- Total entity count
|
||||||
|
- Whether dialogue is active (look for `dialogue` or `conversation` keys
|
||||||
|
with non-null/non-empty values)
|
||||||
|
- Whether monologue is active (look for `monologue` keys with non-null/
|
||||||
|
non-empty values)
|
||||||
|
- NPC names and positions if present
|
||||||
|
- Any error or anomaly fields
|
||||||
|
4. **`seed.txt`** — note the seed value for reference
|
||||||
|
|
||||||
|
Do NOT read `inputs.jsonl` or `snapshots.jsonl` during the summary phase.
|
||||||
|
These are large files reserved for the investigation step.
|
||||||
|
|
||||||
|
### 3. Present the summary list
|
||||||
|
|
||||||
|
Present a numbered list of all reports with their summaries. Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Bug Reports Found: N
|
||||||
|
|
||||||
|
### 1. gauntlet-t{tick}-{timestamp}
|
||||||
|
- **Tick:** {tick} | **Room:** {room} | **Position:** ({x}, {y})
|
||||||
|
- **Stance:** {stance} | **Facing:** {facing}
|
||||||
|
- **Entities:** {count} | **Dialogue active:** yes/no | **Monologue active:** yes/no
|
||||||
|
- **Seed:** {seed}
|
||||||
|
- **Description:** {tester notes, first 2-3 lines}
|
||||||
|
- **Render overview:** {brief description of what render.txt shows}
|
||||||
|
- **Observations:** {any anomalies spotted in the snapshot}
|
||||||
|
|
||||||
|
### 2. gauntlet-t{tick}-{timestamp}
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Offer actions per report
|
||||||
|
|
||||||
|
After presenting the summary list, ask the user which action to take for each
|
||||||
|
report. The three actions are:
|
||||||
|
|
||||||
|
#### Investigate
|
||||||
|
|
||||||
|
Dig deeper into the report for root cause analysis:
|
||||||
|
|
||||||
|
1. Read `snapshot.json` in full — analyze entity states, component values,
|
||||||
|
relationships between entities, any inconsistencies
|
||||||
|
2. Read `inputs.jsonl` — reconstruct what the player was doing in the 60 ticks
|
||||||
|
leading up to the report. Look for:
|
||||||
|
- Rapid input changes (stuck keys, input spam)
|
||||||
|
- Movement into walls or invalid positions
|
||||||
|
- Interaction attempts that may have failed
|
||||||
|
- Timing patterns (actions on same tick as state changes)
|
||||||
|
3. Read `snapshots.jsonl` — compare entity states across recent ticks to find
|
||||||
|
when the bug manifested:
|
||||||
|
- Entity position jumps
|
||||||
|
- State machine transitions that look wrong
|
||||||
|
- Component values going out of expected range
|
||||||
|
- Entities appearing or disappearing unexpectedly
|
||||||
|
4. Cross-reference with `render.txt` to confirm visual manifestation
|
||||||
|
5. Read `seed.txt` and note it — the seed plus `inputs.jsonl` should allow
|
||||||
|
deterministic replay of the scenario
|
||||||
|
|
||||||
|
Present findings as a root cause analysis:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Investigation: gauntlet-t{tick}-{timestamp}
|
||||||
|
|
||||||
|
### Timeline
|
||||||
|
- t{tick-N}: {what happened}
|
||||||
|
- t{tick-M}: {state change}
|
||||||
|
- t{tick}: {bug manifests}
|
||||||
|
|
||||||
|
### Root cause
|
||||||
|
{Analysis of what went wrong and why}
|
||||||
|
|
||||||
|
### Affected systems
|
||||||
|
- {system 1}: {how it's involved}
|
||||||
|
- {system 2}: {how it's involved}
|
||||||
|
|
||||||
|
### Reproduction
|
||||||
|
Seed: {seed}
|
||||||
|
Replay inputs.jsonl from tick {start} to reproduce.
|
||||||
|
|
||||||
|
### Suggested fix
|
||||||
|
{If identifiable from the snapshot data}
|
||||||
|
```
|
||||||
|
|
||||||
|
After investigation, return to the action prompt for this report (the user
|
||||||
|
may want to create a ticket or dismiss after investigating).
|
||||||
|
|
||||||
|
#### Create ticket
|
||||||
|
|
||||||
|
Create a bug ticket in the project database. Determine the team from the
|
||||||
|
nature of the bug:
|
||||||
|
|
||||||
|
- **server** — simulation bugs (entity state, movement, AI, ECS systems,
|
||||||
|
perception, knowledge graph)
|
||||||
|
- **client** — rendering bugs (display glitches, UI issues, input handling,
|
||||||
|
audio, visual artifacts)
|
||||||
|
- **server,client** — integration bugs (protocol mismatch, desync, bridge
|
||||||
|
issues)
|
||||||
|
|
||||||
|
Construct the ticket title and description from the report summary and any
|
||||||
|
investigation findings. Use the ticket CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
db/connectors/ticket create bug "{title}" --team {team} --description "{description}"
|
||||||
|
```
|
||||||
|
|
||||||
|
The description should include:
|
||||||
|
- Bug summary (from tester notes)
|
||||||
|
- Tick, room, position
|
||||||
|
- Key observations from snapshot analysis
|
||||||
|
- Seed for reproduction
|
||||||
|
- Report directory name for reference
|
||||||
|
|
||||||
|
After creating the ticket, report the ticket ID to the user.
|
||||||
|
|
||||||
|
#### Dismiss
|
||||||
|
|
||||||
|
Mark the report as not actionable. Remove the report directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/{report-dir}/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always confirm with the user before deleting.** State clearly which directory
|
||||||
|
will be removed and wait for confirmation.
|
||||||
|
|
||||||
|
### 5. Batch processing
|
||||||
|
|
||||||
|
When processing multiple reports, work through them one at a time in the
|
||||||
|
numbered order presented. For each report, complete the chosen action before
|
||||||
|
moving to the next.
|
||||||
|
|
||||||
|
If the user wants to batch-dismiss multiple reports, confirm the full list
|
||||||
|
of directories that will be deleted before proceeding.
|
||||||
|
|
||||||
|
### 6. Final summary
|
||||||
|
|
||||||
|
After all reports have been processed, present a summary:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Bug Report Processing Complete
|
||||||
|
|
||||||
|
- **Investigated:** {count}
|
||||||
|
- **Tickets created:** {count} ({ticket IDs})
|
||||||
|
- **Dismissed:** {count}
|
||||||
|
- **Remaining unprocessed:** {count}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Large `snapshot.json` files may need to be read with offset/limit parameters.
|
||||||
|
Start with the first 200 lines to get the structure, then target specific
|
||||||
|
sections.
|
||||||
|
- `inputs.jsonl` and `snapshots.jsonl` are newline-delimited JSON. Each line
|
||||||
|
is one tick. Read the last 10-20 lines first to focus on the moments before
|
||||||
|
the report was filed.
|
||||||
|
- The `render.txt` is a text-art representation of the game view. Entity
|
||||||
|
positions in the render should match positions in the snapshot. Mismatches
|
||||||
|
are themselves a bug signal (rendering vs simulation desync).
|
||||||
|
- The seed in `seed.txt` combined with `inputs.jsonl` enables deterministic
|
||||||
|
replay on the server. Note this in any ticket you create.
|
||||||
|
- If the bug-reports directory does not exist, the tester has not yet run any
|
||||||
|
gauntlet sessions or has not filed any reports. This is not an error.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
name: d2-diagram
|
||||||
|
description: >
|
||||||
|
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
|
||||||
|
user says "create a diagram", "draw architecture", "make a flowchart",
|
||||||
|
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
|
||||||
|
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
|
||||||
|
Produces .d2 source files and renders them to PNG. Also use when asked
|
||||||
|
to update, re-render, or batch render existing diagrams.
|
||||||
|
---
|
||||||
|
|
||||||
|
# d2 Diagram Generation
|
||||||
|
|
||||||
|
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
|
||||||
|
external dependencies beyond the d2 binary.
|
||||||
|
|
||||||
|
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
|
||||||
|
|
||||||
|
## Project Defaults
|
||||||
|
|
||||||
|
| Setting | Value | Override |
|
||||||
|
|---------|-------|----------|
|
||||||
|
| Theme | 200 (Dark Mauve) | `--theme N` |
|
||||||
|
| Layout | dagre | `--layout elk` |
|
||||||
|
| Padding | 100px | — |
|
||||||
|
| Format | PNG | `--svg` |
|
||||||
|
|
||||||
|
## Output Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/diagrams/
|
||||||
|
architecture/ # System architecture, IPC, component layout
|
||||||
|
data-flow/ # Sequence diagrams, data pipelines
|
||||||
|
entity/ # ER diagrams, ECS component schemas
|
||||||
|
state/ # State machines, behavior trees
|
||||||
|
ui/ # UI navigation flow, screen transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `.d2` source and `.png` output are tracked in git.
|
||||||
|
|
||||||
|
## Single Diagram Workflow
|
||||||
|
|
||||||
|
1. **Determine category** — architecture, data-flow, entity, state, or ui
|
||||||
|
2. **Read template** — `references/diagram-templates.md` for the matching category
|
||||||
|
3. **Read syntax** — `references/d2-syntax-guide.md` if unfamiliar with d2 syntax
|
||||||
|
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
|
||||||
|
5. **Validate** — `.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
|
||||||
|
6. **Render** — `.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
|
||||||
|
7. **Read SVG** — verify the output, present to user
|
||||||
|
|
||||||
|
### Script Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Render with project defaults
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
|
||||||
|
# Validate syntax only
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
|
||||||
|
# Auto-format source
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
|
||||||
|
# Sketch mode (hand-drawn look for drafts)
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
|
||||||
|
|
||||||
|
# Light theme (for printable docs)
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
|
||||||
|
|
||||||
|
# SVG output (if specifically needed)
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch Render
|
||||||
|
|
||||||
|
Re-render all diagrams after theme or style changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All diagrams
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh
|
||||||
|
|
||||||
|
# One category
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
|
||||||
|
|
||||||
|
# Preview what would render
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
|
||||||
|
|
||||||
|
# Force re-render everything
|
||||||
|
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Variables for consistent styling
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-bg: "#2a3040"
|
||||||
|
color-stroke: "#333340"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-accent: "#c8d8f0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-board (layers)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# Base diagram here
|
||||||
|
|
||||||
|
layers: {
|
||||||
|
detailed: {
|
||||||
|
# More detailed view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequence diagrams
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
client: Godot Client
|
||||||
|
server: Rust Server
|
||||||
|
|
||||||
|
client -> server: TickRequest(delta)
|
||||||
|
server -> client: WorldState(entities)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
Split shared definitions into a separate file and import:
|
||||||
|
|
||||||
|
```d2
|
||||||
|
...@shared-defs.d2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Guidance
|
||||||
|
|
||||||
|
- **Qatux** — Architecture decision records, system overview diagrams, data
|
||||||
|
schemas. Prefer architecture and entity templates.
|
||||||
|
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
|
||||||
|
system data flow. Prefer architecture and data-flow templates.
|
||||||
|
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
|
||||||
|
Prefer UI flow template.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
|
||||||
|
containers, styling, variables). Read when unfamiliar with d2 syntax.
|
||||||
|
- `references/diagram-templates.md` — Five category templates with complete
|
||||||
|
d2 source examples. Read when starting a new diagram.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# D2 Language Quick Reference
|
||||||
|
|
||||||
|
## Nodes
|
||||||
|
|
||||||
|
```d2
|
||||||
|
server # Implicit label from key
|
||||||
|
server: Simulation Server # Explicit label
|
||||||
|
server: Simulation Server { # With properties
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: "#2d3436"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edges
|
||||||
|
|
||||||
|
```d2
|
||||||
|
a -> b # Directed
|
||||||
|
a <- b # Reverse directed
|
||||||
|
a <-> b # Bidirectional
|
||||||
|
a -- b # Undirected
|
||||||
|
a -> b: "label" # Labeled edge
|
||||||
|
a -> b -> c # Chained
|
||||||
|
```
|
||||||
|
|
||||||
|
## Containers (nesting)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
infrastructure: {
|
||||||
|
server: Simulation Server
|
||||||
|
database: State Store {
|
||||||
|
shape: cylinder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
| Shape | Use for |
|
||||||
|
|-------|---------|
|
||||||
|
| `rectangle` | Default. Components, modules, generic. |
|
||||||
|
| `hexagon` | Systems, services, major components. |
|
||||||
|
| `cylinder` | Databases, storage, persistent state. |
|
||||||
|
| `diamond` | Decisions, conditions, branch points. |
|
||||||
|
| `oval` / `circle` | Start/end states, events. |
|
||||||
|
| `cloud` | External systems, networks. |
|
||||||
|
| `person` | Actors, users, NPCs. |
|
||||||
|
| `queue` | Message queues, buffers. |
|
||||||
|
| `page` | Documents, files. |
|
||||||
|
| `package` | Packages, modules, crates. |
|
||||||
|
| `sql_table` | Database tables, ECS component schemas. |
|
||||||
|
| `class` | Class diagrams, ECS system definitions. |
|
||||||
|
| `code` | Code blocks (set `language` property). |
|
||||||
|
| `markdown` | Rich text blocks. |
|
||||||
|
|
||||||
|
## SQL Tables
|
||||||
|
|
||||||
|
```d2
|
||||||
|
entity: {
|
||||||
|
shape: sql_table
|
||||||
|
id: u64 {constraint: primary_key}
|
||||||
|
name: String
|
||||||
|
position: Vec2
|
||||||
|
faction_id: u64 {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Class Diagrams
|
||||||
|
|
||||||
|
```d2
|
||||||
|
perception_system: {
|
||||||
|
shape: class
|
||||||
|
+run(world: &mut World)
|
||||||
|
-calculate_los(entity: Entity): HashSet<Entity>
|
||||||
|
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sequence Diagrams
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
client: Godot Client
|
||||||
|
server: Rust Server
|
||||||
|
|
||||||
|
client -> server: TickRequest(delta)
|
||||||
|
server -> server: run ECS systems
|
||||||
|
server -> client: WorldState(entities)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
```d2
|
||||||
|
node: Label {
|
||||||
|
style: {
|
||||||
|
fill: "#2d3436"
|
||||||
|
stroke: "#333340"
|
||||||
|
stroke-width: 2
|
||||||
|
stroke-dash: 5 # Dashed line
|
||||||
|
opacity: 0.8
|
||||||
|
font-size: 14
|
||||||
|
font-color: "#c8d0e0"
|
||||||
|
bold: true
|
||||||
|
italic: false
|
||||||
|
border-radius: 4
|
||||||
|
shadow: true
|
||||||
|
3d: true # Rectangles only
|
||||||
|
multiple: true # Stacked appearance
|
||||||
|
double-border: true # Rectangles/ovals only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge styling
|
||||||
|
|
||||||
|
```d2
|
||||||
|
a -> b: {
|
||||||
|
style: {
|
||||||
|
stroke: "#c8d8f0"
|
||||||
|
stroke-width: 2
|
||||||
|
stroke-dash: 5
|
||||||
|
opacity: 0.8
|
||||||
|
animated: true # Animated flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-bg: "#1a1e24"
|
||||||
|
color-stroke: "#333340"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-accent: "#c8d8f0"
|
||||||
|
}
|
||||||
|
|
||||||
|
node: {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.stroke: ${color-stroke}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
```d2
|
||||||
|
direction: right # left-to-right (default for dagre)
|
||||||
|
direction: down # top-to-bottom
|
||||||
|
direction: up
|
||||||
|
direction: left
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```d2
|
||||||
|
...@shared-defs.d2 # Spread import (inline all definitions)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
```d2
|
||||||
|
node: Label {
|
||||||
|
icon: https://icons.terrastruct.com/essentials/time.svg
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layers (multi-board)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# Base diagram content here
|
||||||
|
|
||||||
|
layers: {
|
||||||
|
detailed: {
|
||||||
|
# More detailed view
|
||||||
|
}
|
||||||
|
simplified: {
|
||||||
|
# Simplified overview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenarios (animated transitions)
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# Base state
|
||||||
|
|
||||||
|
scenarios: {
|
||||||
|
alert: {
|
||||||
|
# Changes from base for alert state
|
||||||
|
}
|
||||||
|
combat: {
|
||||||
|
# Changes from base for combat state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
```d2
|
||||||
|
# This is a comment
|
||||||
|
node: Label # Inline comment
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Colors (from visual-grammar-v01.md)
|
||||||
|
|
||||||
|
| Constant | Hex | Usage |
|
||||||
|
|----------|-----|-------|
|
||||||
|
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
|
||||||
|
| Zone 1 wall | `#2a3040` | Component fill |
|
||||||
|
| Outline standard | `#333340` | Borders, strokes |
|
||||||
|
| Insert chrome | `#c8d0e0` | Text, labels |
|
||||||
|
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Diagram Templates
|
||||||
|
|
||||||
|
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Diagram
|
||||||
|
|
||||||
|
System components, relationships, communication channels.
|
||||||
|
|
||||||
|
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
|
||||||
|
|
||||||
|
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-bg: "#2a3040"
|
||||||
|
color-stroke: "#333340"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-accent: "#c8d8f0"
|
||||||
|
}
|
||||||
|
|
||||||
|
direction: right
|
||||||
|
|
||||||
|
client: Godot Client {
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
rendering: Rendering {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
ui: UI Layer {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
bridge: IPC Bridge {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
style.stroke: ${color-accent}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server: Rust Server {
|
||||||
|
shape: hexagon
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
ecs: bevy_ecs {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
perception: Perception {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
bridge: IPC Bridge {
|
||||||
|
style.fill: ${color-bg}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
style.stroke: ${color-accent}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.bridge -> server.bridge: "stdin/stdout" {
|
||||||
|
style.stroke: ${color-accent}
|
||||||
|
style.stroke-dash: 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Entity Relationship
|
||||||
|
|
||||||
|
Data schemas, ECS components, knowledge graph structure.
|
||||||
|
|
||||||
|
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
|
||||||
|
|
||||||
|
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
entity: Entity {
|
||||||
|
shape: sql_table
|
||||||
|
id: u64 {constraint: primary_key}
|
||||||
|
name: String
|
||||||
|
faction_id: u64 {constraint: foreign_key}
|
||||||
|
}
|
||||||
|
|
||||||
|
position: Position {
|
||||||
|
shape: sql_table
|
||||||
|
entity_id: u64 {constraint: foreign_key}
|
||||||
|
x: f32
|
||||||
|
y: f32
|
||||||
|
chunk_id: u32
|
||||||
|
}
|
||||||
|
|
||||||
|
knowledge: KnowledgeEntry {
|
||||||
|
shape: sql_table
|
||||||
|
observer_id: u64 {constraint: foreign_key}
|
||||||
|
subject_id: u64 {constraint: foreign_key}
|
||||||
|
fact_type: FactType
|
||||||
|
confidence: f32
|
||||||
|
last_seen_tick: u64
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.id -> position.entity_id
|
||||||
|
entity.id -> knowledge.observer_id
|
||||||
|
entity.id -> knowledge.subject_id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sequence / Data Flow
|
||||||
|
|
||||||
|
Ordered interactions between systems over time.
|
||||||
|
|
||||||
|
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
|
||||||
|
|
||||||
|
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
shape: sequence_diagram
|
||||||
|
|
||||||
|
client: Godot Client
|
||||||
|
bridge: IPC Bridge
|
||||||
|
server: Rust Server
|
||||||
|
ecs: ECS Systems
|
||||||
|
|
||||||
|
client -> bridge: TickRequest(delta, input)
|
||||||
|
bridge -> server: deserialize + dispatch
|
||||||
|
server -> ecs: run_systems(delta)
|
||||||
|
ecs -> ecs: perception, AI, physics
|
||||||
|
ecs -> server: collect WorldState
|
||||||
|
server -> bridge: serialize WorldState
|
||||||
|
bridge -> client: WorldState(entities, events)
|
||||||
|
client -> client: update rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. State Machine
|
||||||
|
|
||||||
|
Entity states, transitions, conditions.
|
||||||
|
|
||||||
|
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
|
||||||
|
|
||||||
|
**Agents:** Tyre (behavior system design), Qatux (state documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-state: "#2a3040"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-edge: "#c8d8f0"
|
||||||
|
color-decision: "#333340"
|
||||||
|
}
|
||||||
|
|
||||||
|
idle: Idle {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert: Alert {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
investigate: Investigate {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
combat: Combat {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
style.stroke: "#f0b840"
|
||||||
|
}
|
||||||
|
|
||||||
|
flee: Flee {
|
||||||
|
style.fill: ${color-state}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
|
||||||
|
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
|
||||||
|
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
|
||||||
|
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
|
||||||
|
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
|
||||||
|
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI Flow
|
||||||
|
|
||||||
|
Screen navigation, component hierarchy, interaction paths.
|
||||||
|
|
||||||
|
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
|
||||||
|
|
||||||
|
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
|
||||||
|
|
||||||
|
```d2
|
||||||
|
vars: {
|
||||||
|
color-screen: "#1a1e24"
|
||||||
|
color-panel: "#2a3040"
|
||||||
|
color-text: "#c8d0e0"
|
||||||
|
color-nav: "#c8d8f0"
|
||||||
|
}
|
||||||
|
|
||||||
|
gameplay: Gameplay {
|
||||||
|
style.fill: ${color-screen}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
hud: HUD {
|
||||||
|
style.fill: ${color-panel}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
minimap: Minimap
|
||||||
|
monologue: Monologue Panel
|
||||||
|
insert_display: Insert Display
|
||||||
|
}
|
||||||
|
|
||||||
|
world: World View {
|
||||||
|
style.fill: ${color-panel}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause: Pause Menu {
|
||||||
|
style.fill: ${color-screen}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
inventory: Inventory
|
||||||
|
journal: Journal
|
||||||
|
settings: Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogue: Dialogue Mode {
|
||||||
|
style.fill: ${color-screen}
|
||||||
|
style.font-color: ${color-text}
|
||||||
|
|
||||||
|
speaker: Speaker Panel
|
||||||
|
responses: Response List
|
||||||
|
}
|
||||||
|
|
||||||
|
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
|
||||||
|
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||||
|
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
|
||||||
|
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||||
|
```
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||||
|
RENDER="$SCRIPT_DIR/d2-render.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [directory] [options]
|
||||||
|
|
||||||
|
Batch render all .d2 files in a directory. Skips files whose PNG is
|
||||||
|
newer than the source unless --force is used.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run List files that would be rendered
|
||||||
|
--force Re-render even if SVG is up to date
|
||||||
|
--theme N Override theme for all files
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") # All in docs/diagrams/
|
||||||
|
$(basename "$0") docs/diagrams/architecture/ # One category
|
||||||
|
$(basename "$0") --dry-run # Preview
|
||||||
|
$(basename "$0") --force # Re-render everything
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
DIR="$REPO_ROOT/docs/diagrams"
|
||||||
|
DRY_RUN=false
|
||||||
|
FORCE=false
|
||||||
|
EXTRA_ARGS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--force) FORCE=true; shift ;;
|
||||||
|
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
|
||||||
|
--help|-h) usage ;;
|
||||||
|
*)
|
||||||
|
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
|
||||||
|
DIR="$1"
|
||||||
|
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
|
||||||
|
else
|
||||||
|
echo "Unknown option or directory: $1" >&2; exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
|
||||||
|
|
||||||
|
RENDERED=0
|
||||||
|
SKIPPED=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' d2_file; do
|
||||||
|
png_file="${d2_file%.d2}.png"
|
||||||
|
|
||||||
|
# Skip if PNG is newer than source (unless --force)
|
||||||
|
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
rel_path="${d2_file#"$REPO_ROOT/"}"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
echo "Would render: $rel_path"
|
||||||
|
RENDERED=$((RENDERED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
|
||||||
|
RENDERED=$((RENDERED + 1))
|
||||||
|
else
|
||||||
|
echo "FAILED: $rel_path" >&2
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
D2="/home/linuxbrew/.linuxbrew/bin/d2"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
DEFAULT_THEME=200
|
||||||
|
DEFAULT_LAYOUT="dagre"
|
||||||
|
DEFAULT_PAD=100
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
|
||||||
|
|
||||||
|
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
validate <file> Check syntax without rendering
|
||||||
|
fmt <file> Auto-format in place
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--theme N Override theme (default: $DEFAULT_THEME)
|
||||||
|
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
|
||||||
|
--sketch Enable hand-drawn sketch mode
|
||||||
|
--output PATH Override output path (default: input with .png extension)
|
||||||
|
--svg Render to SVG instead of PNG
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
|
||||||
|
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
# Parse subcommand
|
||||||
|
SUBCMD=""
|
||||||
|
case "$1" in
|
||||||
|
validate|fmt)
|
||||||
|
SUBCMD="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
INPUT="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
# Resolve to absolute path
|
||||||
|
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
|
||||||
|
|
||||||
|
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Handle subcommands
|
||||||
|
if [[ -n "$SUBCMD" ]]; then
|
||||||
|
"$D2" "$SUBCMD" "$INPUT"
|
||||||
|
echo "OK: $SUBCMD $INPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse render options
|
||||||
|
THEME="$DEFAULT_THEME"
|
||||||
|
LAYOUT="$DEFAULT_LAYOUT"
|
||||||
|
SKETCH=""
|
||||||
|
OUTPUT=""
|
||||||
|
FORMAT="png"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--theme) THEME="$2"; shift 2 ;;
|
||||||
|
--layout) LAYOUT="$2"; shift 2 ;;
|
||||||
|
--sketch) SKETCH="-s"; shift ;;
|
||||||
|
--output) OUTPUT="$2"; shift 2 ;;
|
||||||
|
--svg) FORMAT="svg"; shift ;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Derive output path
|
||||||
|
if [[ -z "$OUTPUT" ]]; then
|
||||||
|
OUTPUT="${INPUT%.d2}.$FORMAT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render
|
||||||
|
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
|
||||||
|
|
||||||
|
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
|
||||||
|
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
|
||||||
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
|
|||||||
|
|
||||||
# Search Docs Skill
|
# Search Docs Skill
|
||||||
|
|
||||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
Semantic search across project documents. Endpoints are in
|
||||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
and workflows.
|
||||||
|
|
||||||
## Advanced Commands
|
## Advanced Commands
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
---
|
||||||
|
name: frame0-wireframe
|
||||||
|
description: >
|
||||||
|
Create and export UI wireframes using Frame0 (local desktop wireframing
|
||||||
|
app with HTTP API). Use when the user says "create wireframe", "wireframe
|
||||||
|
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
|
||||||
|
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
|
||||||
|
Wireframes are authored as local JSON files (source of truth) and synced
|
||||||
|
to Frame0 for rendering and export. Requires Frame0 to be running locally.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frame0 Wireframe Generation
|
||||||
|
|
||||||
|
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
|
||||||
|
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
|
||||||
|
|
||||||
|
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
|
||||||
|
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
|
||||||
|
the user explicitly says they have made edits in Frame0 and want to import
|
||||||
|
them. The pull workflow exists for that case only — do not use it proactively.
|
||||||
|
|
||||||
|
**Prerequisite:** Frame0 desktop app must be running. If not available,
|
||||||
|
stop and inform the user. Point to `references/setup-guide.md`.
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
Always check first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Workflow
|
||||||
|
|
||||||
|
1. **Health check** — verify Frame0 is running
|
||||||
|
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`
|
||||||
|
3. **Push to Frame0** — `frame0-sync.py push <file.json>`
|
||||||
|
4. **Export PNG** — `frame0-sync.py export <file.json> <output.png>`
|
||||||
|
5. **Clean up** — delete test/scratch pages from Frame0 when done
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
|
||||||
|
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
|
||||||
|
|
||||||
|
## Wireframe JSON Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Dialogue Box",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||||
|
"fillColor": "#1a1e24",
|
||||||
|
"strokeColor": "#333340",
|
||||||
|
"corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"speaker": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 520,
|
||||||
|
"text": "LERA KONSTANTIN",
|
||||||
|
"fontColor": "#c8d0e0",
|
||||||
|
"fontSize": 16
|
||||||
|
},
|
||||||
|
"btn-ask": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||||
|
"fillColor": "#2a3040",
|
||||||
|
"strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectors": {
|
||||||
|
"flow-1": {
|
||||||
|
"tailId": "panel",
|
||||||
|
"headId": "btn-ask",
|
||||||
|
"strokeColor": "#c8d8f0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key rules
|
||||||
|
|
||||||
|
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
|
||||||
|
- **`parent`** references another local shape ID for nesting
|
||||||
|
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
|
||||||
|
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
|
||||||
|
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
|
||||||
|
theme tokens for colors). The sync script handles the mapping transparently.
|
||||||
|
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
|
||||||
|
|
||||||
|
### Sync commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
|
||||||
|
|
||||||
|
# Push local JSON to Frame0 (clears page, recreates all shapes)
|
||||||
|
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
|
||||||
|
|
||||||
|
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
|
||||||
|
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
|
||||||
|
|
||||||
|
# Push + export as PNG in one step
|
||||||
|
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
|
||||||
|
docs/design/wireframes/dialogue/dialogue-box.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch export
|
||||||
|
|
||||||
|
Use this when exporting multiple wireframes. It runs as a single Bash call,
|
||||||
|
avoiding repeated permission prompts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
|
||||||
|
|
||||||
|
# Dry run first — shows full manifest, no Frame0 calls
|
||||||
|
$BATCH --dry-run
|
||||||
|
|
||||||
|
# Export everything (skips PNGs already newer than their JSON)
|
||||||
|
$BATCH
|
||||||
|
|
||||||
|
# Export one category only
|
||||||
|
$BATCH --category dialogue
|
||||||
|
|
||||||
|
# Force re-export of everything
|
||||||
|
$BATCH --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always dry-run first, then get approval before running the live export.**
|
||||||
|
|
||||||
|
## Low-Level Commands
|
||||||
|
|
||||||
|
For ad-hoc operations or exec calls not covered by sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
|
||||||
|
$CMD health
|
||||||
|
$CMD list-pages
|
||||||
|
$CMD current-page
|
||||||
|
$CMD get-page <page-id>
|
||||||
|
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||||
|
$CMD create-connector <tail-id> <head-id>
|
||||||
|
$CMD move <shape-id> <dx> <dy>
|
||||||
|
$CMD export --format image/png
|
||||||
|
$CMD exec "view:fit-to-screen" '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
If you find yourself using `exec` for the same command repeatedly, flag it as
|
||||||
|
a candidate for a proper wrapper in `frame0-cmd.sh`.
|
||||||
|
|
||||||
|
## Project Styling Defaults
|
||||||
|
|
||||||
|
Colors from `docs/design/visual-grammar-v01.md`:
|
||||||
|
|
||||||
|
| Role | Hex | Frame0 token |
|
||||||
|
|------|-----|-------------|
|
||||||
|
| Background | `#1a1e24` | `$sage3` |
|
||||||
|
| Stroke | `#333340` | `$slate6` |
|
||||||
|
| Fill | `#2a3040` | `$slate5` |
|
||||||
|
| Text | `#c8d0e0` | `$mint12` |
|
||||||
|
| Accent | `#c8d8f0` | `$blue12` |
|
||||||
|
|
||||||
|
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
|
||||||
|
|
||||||
|
## Output Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/design/wireframes/
|
||||||
|
hud/ # HUD layout wireframes
|
||||||
|
menus/ # Menu screen wireframes
|
||||||
|
dialogue/ # Dialogue box wireframes
|
||||||
|
insert/ # Neural insert wireframes
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `.json` source and `.png` exports are tracked in git.
|
||||||
|
`.idmap.json` mapping files are gitignored.
|
||||||
|
|
||||||
|
## Agent Guidance
|
||||||
|
|
||||||
|
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
|
||||||
|
visual consistency. Uses all component library patterns.
|
||||||
|
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
|
||||||
|
- **Qatux** — Export wireframes for UI decision records and documentation.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `references/component-library.md` — Pre-built JSON wireframe templates
|
||||||
|
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
|
||||||
|
- `references/api-reference.md` — Frame0 HTTP API command reference and
|
||||||
|
type mappings. Read for low-level control.
|
||||||
|
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Frame0 HTTP API Reference
|
||||||
|
|
||||||
|
Frame0 exposes a local HTTP API when the desktop app is running.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://localhost:{port}/execute_command
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
|
||||||
|
|
||||||
|
## Request / Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "namespace:action", "args": { ... }}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"success": true, "data": { ... }}
|
||||||
|
{"success": false, "error": "description"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Mapping
|
||||||
|
|
||||||
|
Frame0 uses different type names for create vs get:
|
||||||
|
|
||||||
|
| Create API (`type`) | Get API (internal) | Description |
|
||||||
|
|--------------------|--------------------|-------------|
|
||||||
|
| `Rectangle` | `Box` | Rectangle with optional corners |
|
||||||
|
| `Ellipse` | `Ellipse` | Circle/ellipse |
|
||||||
|
| `Text` | `Text` | Text label |
|
||||||
|
| `Line` | `Line` | Line/polyline |
|
||||||
|
| `Frame` | `Frame` | Container from library |
|
||||||
|
| `Freehand` | `Freehand` | Freehand drawing |
|
||||||
|
| `Highlighter` | `Highlighter` | Highlighter stroke |
|
||||||
|
|
||||||
|
The sync script handles this mapping transparently.
|
||||||
|
|
||||||
|
## Color Tokens
|
||||||
|
|
||||||
|
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
|
||||||
|
|
||||||
|
| Hex | Token | Role |
|
||||||
|
|-----|-------|------|
|
||||||
|
| `#1a1e24` | `$sage3` | Background |
|
||||||
|
| `#2a3040` | `$slate5` | Fill |
|
||||||
|
| `#333340` | `$slate6` | Stroke |
|
||||||
|
| `#c8d0e0` | `$mint12` | Text |
|
||||||
|
| `#c8d8f0` | `$blue12` | Accent |
|
||||||
|
|
||||||
|
Both hex and token strings work in the API. Tokens are preserved on round-trip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### shape:create-shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:create-shape",
|
||||||
|
"args": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"shapeProps": {
|
||||||
|
"name": "my-button",
|
||||||
|
"left": 100, "top": 200, "width": 120, "height": 36,
|
||||||
|
"fillColor": "#2a3040",
|
||||||
|
"strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"parentId": "optional-parent-shape-id",
|
||||||
|
"convertColors": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: shape ID (string).
|
||||||
|
|
||||||
|
### shape:get-shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:update-shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:update-shape",
|
||||||
|
"args": {
|
||||||
|
"shapeId": "id",
|
||||||
|
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
|
||||||
|
"convertColors": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:move
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:create-connector
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:create-connector",
|
||||||
|
"args": {
|
||||||
|
"tailId": "source-id",
|
||||||
|
"headId": "target-id",
|
||||||
|
"shapeProps": {"strokeColor": "#c8d8f0"},
|
||||||
|
"convertColors": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:create-icon
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "shape:create-icon",
|
||||||
|
"args": {
|
||||||
|
"iconName": "search",
|
||||||
|
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:get-available-icons
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:get-available-icons", "args": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shape:group / shape:ungroup
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||||
|
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### edit:delete / edit:duplicate
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||||
|
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### page:add
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: `{id, type, name}`.
|
||||||
|
|
||||||
|
### page:get
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### page:get-current-page
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:get-current-page", "args": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns: page ID string.
|
||||||
|
|
||||||
|
### page:set-current-page
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:set-current-page", "args": {"pageId": "id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### doc:get (list all pages)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### page:delete
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "page:delete", "args": {"pageId": "id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### file:export-image
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "file:export-image",
|
||||||
|
"args": {
|
||||||
|
"pageId": "optional-page-id",
|
||||||
|
"format": "image/png",
|
||||||
|
"fillBackground": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
|
||||||
|
Returns: base64-encoded image data.
|
||||||
|
|
||||||
|
### view:fit-to-screen
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"command": "view:fit-to-screen", "args": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shape Properties
|
||||||
|
|
||||||
|
| Property | Type | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| `name` | string | Shape identifier/label |
|
||||||
|
| `left` | number | X position (origin: top-left) |
|
||||||
|
| `top` | number | Y position |
|
||||||
|
| `width` | number | Width in pixels |
|
||||||
|
| `height` | number | Height in pixels |
|
||||||
|
| `fillColor` | string | Hex or `$token` |
|
||||||
|
| `strokeColor` | string | Hex or `$token` |
|
||||||
|
| `strokeWidth` | number | Border width |
|
||||||
|
| `fontColor` | string | Text color (hex or `$token`) |
|
||||||
|
| `fontSize` | number | Font size in pixels |
|
||||||
|
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
|
||||||
|
| `text` | string | Text content |
|
||||||
|
| `wordWrap` | boolean | Enable word wrapping |
|
||||||
|
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
|
||||||
|
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
|
||||||
|
| `constraints` | array | Auto-sizing constraints |
|
||||||
|
| `horzAlign` | string | Horizontal text alignment |
|
||||||
|
| `vertAlign` | string | Vertical text alignment |
|
||||||
|
| `fillStyle` | string | Fill style (`none` for transparent) |
|
||||||
|
| `path` | array | Coordinate pairs for lines |
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# Component Library
|
||||||
|
|
||||||
|
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
|
||||||
|
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
|
||||||
|
|
||||||
|
**Viewport:** 1140x780 (Godot project settings)
|
||||||
|
**Grid unit:** 8px
|
||||||
|
**Min touch target:** 36px height
|
||||||
|
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. HUD Layout
|
||||||
|
|
||||||
|
Main gameplay overlay. Minimap top-right, monologue bottom-center,
|
||||||
|
insert display bottom-left, action hints bottom-right.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "HUD Layout",
|
||||||
|
"shapes": {
|
||||||
|
"minimap": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 880, "top": 20, "width": 240, "height": 240,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"minimap-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "minimap",
|
||||||
|
"left": 890, "top": 30,
|
||||||
|
"text": "Minimap",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"monologue": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 300, "top": 680, "width": 520, "height": 80,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"monologue-text": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "monologue",
|
||||||
|
"left": 310, "top": 700, "width": 500,
|
||||||
|
"text": "Internal monologue text appears here...",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
|
||||||
|
},
|
||||||
|
"insert": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 20, "top": 600, "width": 260, "height": 160,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"insert-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "insert",
|
||||||
|
"left": 30, "top": 620,
|
||||||
|
"text": "Neural Insert Data",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 880, "top": 700, "width": 240, "height": 60,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"hints-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "hints",
|
||||||
|
"left": 890, "top": 720,
|
||||||
|
"text": "[E] Interact [TAB] Insert",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dialogue Box
|
||||||
|
|
||||||
|
Speaker panel with response options. Anchored bottom-center during dialogue mode.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Dialogue Box",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"speaker": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 520,
|
||||||
|
"text": "LERA KONSTANTIN",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 16
|
||||||
|
},
|
||||||
|
"text-area": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 550, "width": 760, "height": 100,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"dialogue-text": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "text-area",
|
||||||
|
"left": 200, "top": 560, "width": 740,
|
||||||
|
"text": "You look like you could use a drink. First time on the station?",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||||
|
},
|
||||||
|
"btn-option1": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-option1-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "btn-option1",
|
||||||
|
"left": 200, "top": 674,
|
||||||
|
"text": "[1] Ask about the station",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 12
|
||||||
|
},
|
||||||
|
"btn-option2": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 190, "top": 710, "width": 370, "height": 30,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-option2-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "btn-option2",
|
||||||
|
"left": 200, "top": 714,
|
||||||
|
"text": "[2] Ask about recent events",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 12
|
||||||
|
},
|
||||||
|
"btn-leave": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "panel",
|
||||||
|
"left": 580, "top": 670, "width": 180, "height": 30,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-leave-label": {
|
||||||
|
"type": "Text",
|
||||||
|
"parent": "btn-leave",
|
||||||
|
"left": 590, "top": 674,
|
||||||
|
"text": "[3] Leave",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Menu Screen
|
||||||
|
|
||||||
|
Full-screen menu with sidebar navigation and content area.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Pause Menu",
|
||||||
|
"shapes": {
|
||||||
|
"bg": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||||
|
"fillColor": "#1a1e24"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "bg",
|
||||||
|
"left": 20, "top": 20, "width": 200, "height": 740,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-inventory": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 40, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-inventory-label": {
|
||||||
|
"type": "Text", "parent": "btn-inventory",
|
||||||
|
"left": 40, "top": 48, "text": "Inventory",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-journal": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 86, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-journal-label": {
|
||||||
|
"type": "Text", "parent": "btn-journal",
|
||||||
|
"left": 40, "top": 94, "text": "Journal",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-map": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 132, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-map-label": {
|
||||||
|
"type": "Text", "parent": "btn-map",
|
||||||
|
"left": 40, "top": 140, "text": "Map",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-settings": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 178, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-settings-label": {
|
||||||
|
"type": "Text", "parent": "btn-settings",
|
||||||
|
"left": 40, "top": 186, "text": "Settings",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-resume": {
|
||||||
|
"type": "Rectangle", "parent": "nav",
|
||||||
|
"left": 30, "top": 720, "width": 180, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-resume-label": {
|
||||||
|
"type": "Text", "parent": "btn-resume",
|
||||||
|
"left": 40, "top": 728, "text": "Resume",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "bg",
|
||||||
|
"left": 240, "top": 20, "width": 880, "height": 740,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||||
|
"corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"content-label": {
|
||||||
|
"type": "Text", "parent": "content",
|
||||||
|
"left": 260, "top": 40,
|
||||||
|
"text": "Content area",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modal Dialog
|
||||||
|
|
||||||
|
Centered overlay for confirmations, alerts, choices.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Modal Dialog",
|
||||||
|
"shapes": {
|
||||||
|
"overlay": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||||
|
"fillColor": "#0a0c10"
|
||||||
|
},
|
||||||
|
"modal": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"parent": "overlay",
|
||||||
|
"left": 320, "top": 240, "width": 500, "height": 300,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||||
|
"corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "Text", "parent": "modal",
|
||||||
|
"left": 340, "top": 260,
|
||||||
|
"text": "Confirm Action",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 18
|
||||||
|
},
|
||||||
|
"divider": {
|
||||||
|
"type": "Line", "parent": "modal",
|
||||||
|
"left": 340, "top": 290, "width": 460, "height": 0,
|
||||||
|
"strokeColor": "#333340"
|
||||||
|
},
|
||||||
|
"body-1": {
|
||||||
|
"type": "Text", "parent": "modal",
|
||||||
|
"left": 340, "top": 310,
|
||||||
|
"text": "Are you sure you want to proceed?",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"body-2": {
|
||||||
|
"type": "Text", "parent": "modal",
|
||||||
|
"left": 340, "top": 340,
|
||||||
|
"text": "This action cannot be undone.",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-cancel": {
|
||||||
|
"type": "Rectangle", "parent": "modal",
|
||||||
|
"left": 480, "top": 480, "width": 120, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-cancel-label": {
|
||||||
|
"type": "Text", "parent": "btn-cancel",
|
||||||
|
"left": 510, "top": 488,
|
||||||
|
"text": "Cancel",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"btn-confirm": {
|
||||||
|
"type": "Rectangle", "parent": "modal",
|
||||||
|
"left": 620, "top": 480, "width": 120, "height": 36,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"btn-confirm-label": {
|
||||||
|
"type": "Text", "parent": "btn-confirm",
|
||||||
|
"left": 645, "top": 488,
|
||||||
|
"text": "Confirm",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. List View
|
||||||
|
|
||||||
|
Scrollable list with item selection and detail panel.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "List View",
|
||||||
|
"shapes": {
|
||||||
|
"list-panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 20, "top": 20, "width": 400, "height": 740,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-1": {
|
||||||
|
"type": "Rectangle", "parent": "list-panel",
|
||||||
|
"left": 30, "top": 30, "width": 380, "height": 40,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-1-label": {
|
||||||
|
"type": "Text", "parent": "item-1",
|
||||||
|
"left": 40, "top": 38, "text": "Item Alpha",
|
||||||
|
"fontColor": "#c8d8f0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"item-2": {
|
||||||
|
"type": "Rectangle", "parent": "list-panel",
|
||||||
|
"left": 30, "top": 80, "width": 380, "height": 40,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-2-label": {
|
||||||
|
"type": "Text", "parent": "item-2",
|
||||||
|
"left": 40, "top": 88, "text": "Item Beta",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"item-3": {
|
||||||
|
"type": "Rectangle", "parent": "list-panel",
|
||||||
|
"left": 30, "top": 130, "width": 380, "height": 40,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"item-3-label": {
|
||||||
|
"type": "Text", "parent": "item-3",
|
||||||
|
"left": 40, "top": 138, "text": "Item Gamma",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14
|
||||||
|
},
|
||||||
|
"detail-panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 440, "top": 20, "width": 680, "height": 740,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"detail-title": {
|
||||||
|
"type": "Text", "parent": "detail-panel",
|
||||||
|
"left": 460, "top": 40,
|
||||||
|
"text": "Item Alpha",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 18
|
||||||
|
},
|
||||||
|
"detail-body": {
|
||||||
|
"type": "Text", "parent": "detail-panel",
|
||||||
|
"left": 460, "top": 80, "width": 640,
|
||||||
|
"text": "Description and properties appear here.",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Inventory Grid
|
||||||
|
|
||||||
|
Grid of cells for item management.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Inventory Grid",
|
||||||
|
"shapes": {
|
||||||
|
"panel": {
|
||||||
|
"type": "Rectangle",
|
||||||
|
"left": 240, "top": 100, "width": 660, "height": 580,
|
||||||
|
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "Text", "parent": "panel",
|
||||||
|
"left": 260, "top": 120,
|
||||||
|
"text": "INVENTORY",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 18
|
||||||
|
},
|
||||||
|
"cell-1-1": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 260, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-1-2": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 332, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-1-3": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 404, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-1-4": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 476, "top": 160, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-1": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 260, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-2": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 332, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-3": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 404, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"cell-2-4": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 476, "top": 232, "width": 64, "height": 64,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"type": "Rectangle", "parent": "panel",
|
||||||
|
"left": 580, "top": 160, "width": 300, "height": 400,
|
||||||
|
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||||
|
},
|
||||||
|
"detail-title": {
|
||||||
|
"type": "Text", "parent": "detail",
|
||||||
|
"left": 600, "top": 180,
|
||||||
|
"text": "Selected Item Name",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 16
|
||||||
|
},
|
||||||
|
"detail-body": {
|
||||||
|
"type": "Text", "parent": "detail",
|
||||||
|
"left": 600, "top": 210, "width": 260,
|
||||||
|
"text": "Item description and stats",
|
||||||
|
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Frame0 Setup Guide
|
||||||
|
|
||||||
|
## Installation (Fedora)
|
||||||
|
|
||||||
|
Download from https://frame0.app/download and install the RPM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install ./frame0-*.x86_64.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires: Fedora 40 or later (x86_64).
|
||||||
|
|
||||||
|
## Starting Frame0
|
||||||
|
|
||||||
|
Launch the desktop application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
frame0 &
|
||||||
|
```
|
||||||
|
|
||||||
|
Frame0 exposes an HTTP API at `localhost:58320` when running.
|
||||||
|
|
||||||
|
## Verify API Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `Frame0 is running on port 58320`
|
||||||
|
|
||||||
|
## Port Configuration
|
||||||
|
|
||||||
|
Default port: **58320**
|
||||||
|
|
||||||
|
To use a different port, set the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FRAME0_PORT=58321
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pass `--port` to any script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| "Connection refused" | Frame0 not running | Start the desktop app |
|
||||||
|
| "Port in use" | Another instance running | Close duplicate or use different port |
|
||||||
|
| Script hangs | API unresponsive | Restart Frame0 |
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${FRAME0_PORT:-58320}"
|
||||||
|
ENDPOINT="http://localhost:${PORT}/execute_command"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") <command> [args...] [--port N]
|
||||||
|
|
||||||
|
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
|
||||||
|
curl calls. Requires Frame0 desktop app to be running.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
health Check if Frame0 is running
|
||||||
|
exec <namespace:action> <json> Execute a raw API command
|
||||||
|
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
|
||||||
|
get-shape <id> Get shape details
|
||||||
|
update-shape <id> <json-props> Update shape properties
|
||||||
|
delete <id> [id...] Delete shapes by ID
|
||||||
|
move <id> <dx> <dy> Move a shape by pixel offset
|
||||||
|
duplicate <id> Duplicate a shape
|
||||||
|
group <id> [id...] Group shapes
|
||||||
|
ungroup <group-id> Ungroup a group
|
||||||
|
create-connector <tail-id> <head-id> [json-props] Connect two shapes
|
||||||
|
create-icon <name> <json-props> Create an icon shape
|
||||||
|
add-page <name> Add a new page (becomes current)
|
||||||
|
get-page [page-id] Get current or specific page data
|
||||||
|
list-pages [--shapes] List all pages (--shapes for shape data)
|
||||||
|
current-page Get current page ID
|
||||||
|
set-page <page-id> Set current page
|
||||||
|
export [page-id] [--format mime] Export page as image (default: image/png)
|
||||||
|
fit Fit view to screen
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") health
|
||||||
|
$(basename "$0") add-page "HUD Layout"
|
||||||
|
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||||
|
$(basename "$0") list-pages
|
||||||
|
$(basename "$0") export --format image/png
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse --port from anywhere in args
|
||||||
|
ARGS=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
|
||||||
|
*) ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
# Execute a Frame0 API command, return data or error
|
||||||
|
frame0_exec() {
|
||||||
|
local command="$1"
|
||||||
|
local args
|
||||||
|
args="${2:-"{}"}"
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
|
||||||
|
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
|
||||||
|
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
local http_code body
|
||||||
|
http_code=$(echo "$response" | tail -1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
if [[ "$http_code" != 2* ]]; then
|
||||||
|
echo "ERROR: HTTP $http_code from Frame0" >&2
|
||||||
|
echo "$body" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse success/error from response
|
||||||
|
python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
r = json.loads(sys.stdin.read())
|
||||||
|
if r.get('success'):
|
||||||
|
d = r.get('data')
|
||||||
|
if d is not None:
|
||||||
|
print(json.dumps(d, indent=2))
|
||||||
|
else:
|
||||||
|
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
" <<< "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build JSON array from remaining args
|
||||||
|
ids_to_json_array() {
|
||||||
|
local arr="["
|
||||||
|
local first=true
|
||||||
|
for id in "$@"; do
|
||||||
|
[[ "$first" == true ]] && first=false || arr+=","
|
||||||
|
arr+="\"$id\""
|
||||||
|
done
|
||||||
|
arr+="]"
|
||||||
|
echo "$arr"
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
health)
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
|
||||||
|
echo "Frame0 is running on port $PORT"
|
||||||
|
else
|
||||||
|
echo "Frame0 is NOT running on port $PORT" >&2
|
||||||
|
echo "Start Frame0 desktop app, then retry." >&2
|
||||||
|
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
exec)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
|
||||||
|
frame0_exec "$1" "$2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
create-shape)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
|
||||||
|
local_type="$1"
|
||||||
|
local_props="$2"
|
||||||
|
local_parent="${3:-}"
|
||||||
|
local_parent_arg=""
|
||||||
|
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
|
||||||
|
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
get-shape)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
update-shape)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
delete)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
|
||||||
|
local_arr=$(ids_to_json_array "$@")
|
||||||
|
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
move)
|
||||||
|
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
duplicate)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
|
||||||
|
local_dx="${2:-0}"
|
||||||
|
local_dy="${3:-0}"
|
||||||
|
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
group)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
|
||||||
|
local_arr=$(ids_to_json_array "$@")
|
||||||
|
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
ungroup)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
create-connector)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
|
||||||
|
local_props="${3:-{}}"
|
||||||
|
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
create-icon)
|
||||||
|
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
|
||||||
|
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
add-page)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
|
||||||
|
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
get-page)
|
||||||
|
if [[ $# -ge 1 ]]; then
|
||||||
|
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
|
||||||
|
else
|
||||||
|
local_id
|
||||||
|
local_id=$(frame0_exec "page:get-current-page")
|
||||||
|
# Strip quotes from returned ID
|
||||||
|
local_id=$(echo "$local_id" | tr -d '"')
|
||||||
|
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
list-pages)
|
||||||
|
local_shapes="false"
|
||||||
|
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
|
||||||
|
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
current-page)
|
||||||
|
frame0_exec "page:get-current-page"
|
||||||
|
;;
|
||||||
|
|
||||||
|
set-page)
|
||||||
|
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
|
||||||
|
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
export)
|
||||||
|
local_page_id=""
|
||||||
|
local_format="image/png"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--format) local_format="$2"; shift 2 ;;
|
||||||
|
*) local_page_id="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
local_page_arg=""
|
||||||
|
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
|
||||||
|
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
fit)
|
||||||
|
frame0_exec "view:fit-to-screen"
|
||||||
|
;;
|
||||||
|
|
||||||
|
--help|-h|help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $CMD" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Batch export wireframes from JSON to PNG via Frame0.
|
||||||
|
#
|
||||||
|
# Finds all .json wireframe files under docs/design/wireframes/ and exports
|
||||||
|
# each to a matching .png. Skips files whose PNG is already newer than the
|
||||||
|
# JSON, unless --force is passed.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --dry-run Print manifest only, don't touch Frame0.
|
||||||
|
# --force Re-export even if PNG already exists and is up to date.
|
||||||
|
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
|
||||||
|
# --root DIR Wireframes root dir (default: docs/design/wireframes)
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 All exports succeeded (or nothing to do)
|
||||||
|
# 1 One or more exports failed
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||||
|
SYNC="$SCRIPT_DIR/frame0-sync.py"
|
||||||
|
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
FORCE=false
|
||||||
|
CATEGORY=""
|
||||||
|
WF_ROOT="$DEFAULT_ROOT"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--force) FORCE=true; shift ;;
|
||||||
|
--category) CATEGORY="$2"; shift 2 ;;
|
||||||
|
--root) WF_ROOT="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '/^# /p' "$0" | sed 's/^# //'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -d "$WF_ROOT" ]]; then
|
||||||
|
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect JSON files, optionally filtered by category subdirectory
|
||||||
|
mapfile -t JSON_FILES < <(
|
||||||
|
if [[ -n "$CATEGORY" ]]; then
|
||||||
|
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
|
||||||
|
else
|
||||||
|
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
|
||||||
|
echo "No wireframe JSON files found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Classify files into to-export and to-skip
|
||||||
|
TO_EXPORT=()
|
||||||
|
TO_SKIP=()
|
||||||
|
|
||||||
|
for json in "${JSON_FILES[@]}"; do
|
||||||
|
png="${json%.json}.png"
|
||||||
|
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
|
||||||
|
TO_EXPORT+=("$json")
|
||||||
|
else
|
||||||
|
TO_SKIP+=("$json")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Print manifest
|
||||||
|
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Will export (${#TO_EXPORT[@]} files):"
|
||||||
|
for json in "${TO_EXPORT[@]}"; do
|
||||||
|
png="${json%.json}.png"
|
||||||
|
rel="${json#$REPO_ROOT/}"
|
||||||
|
if [[ ! -f "$png" ]]; then
|
||||||
|
status="new"
|
||||||
|
else
|
||||||
|
status="updated"
|
||||||
|
fi
|
||||||
|
printf " [%-7s] %s\n" "$status" "$rel"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Nothing to export (all PNGs up to date)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
|
||||||
|
for json in "${TO_SKIP[@]}"; do
|
||||||
|
rel="${json#$REPO_ROOT/}"
|
||||||
|
printf " [skip ] %s\n" "$rel"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo ""
|
||||||
|
echo "Dry run — no exports performed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
PASSED=0
|
||||||
|
FAILED=0
|
||||||
|
FAILED_FILES=()
|
||||||
|
|
||||||
|
TOTAL=${#TO_EXPORT[@]}
|
||||||
|
IDX=0
|
||||||
|
|
||||||
|
for json in "${TO_EXPORT[@]}"; do
|
||||||
|
IDX=$((IDX + 1))
|
||||||
|
png="${json%.json}.png"
|
||||||
|
rel="${json#$REPO_ROOT/}"
|
||||||
|
|
||||||
|
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
|
||||||
|
|
||||||
|
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
|
||||||
|
rc=$?
|
||||||
|
if [[ $rc -eq 0 ]]; then
|
||||||
|
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
|
||||||
|
echo "ok $size"
|
||||||
|
PASSED=$((PASSED + 1))
|
||||||
|
else
|
||||||
|
echo "FAILED"
|
||||||
|
cat /tmp/frame0-batch-err.txt >&2
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
FAILED_FILES+=("$rel")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "$PASSED exported, $FAILED failed."
|
||||||
|
|
||||||
|
if [[ $FAILED -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Failed:" >&2
|
||||||
|
for f in "${FAILED_FILES[@]}"; do
|
||||||
|
echo " $f" >&2
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
|
||||||
|
|
||||||
|
Local JSON is source of truth. Frame0 is a renderer.
|
||||||
|
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
frame0-sync.py push <wireframe.json> [--port PORT]
|
||||||
|
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
|
||||||
|
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
DEFAULT_PORT = 58320
|
||||||
|
|
||||||
|
|
||||||
|
def api(port, command, args=None):
|
||||||
|
"""Execute a Frame0 API command. Returns the data field on success."""
|
||||||
|
url = f"http://localhost:{port}/execute_command"
|
||||||
|
payload = json.dumps({"command": command, "args": args or {}}).encode()
|
||||||
|
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode()
|
||||||
|
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return result.get("data")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Mapping file management --------------------------------------------------
|
||||||
|
|
||||||
|
def mapping_path(wireframe_path):
|
||||||
|
"""Derive the idmap path from the wireframe JSON path."""
|
||||||
|
d = os.path.dirname(wireframe_path)
|
||||||
|
base = os.path.splitext(os.path.basename(wireframe_path))[0]
|
||||||
|
return os.path.join(d, f".{base}.idmap.json")
|
||||||
|
|
||||||
|
|
||||||
|
def load_mapping(wireframe_path):
|
||||||
|
p = mapping_path(wireframe_path)
|
||||||
|
if os.path.exists(p):
|
||||||
|
with open(p) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {"page_id": None, "shapes": {}, "connectors": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_mapping(wireframe_path, mapping):
|
||||||
|
p = mapping_path(wireframe_path)
|
||||||
|
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||||
|
with open(p, "w") as f:
|
||||||
|
json.dump(mapping, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Push: local JSON -> Frame0 -----------------------------------------------
|
||||||
|
|
||||||
|
# Frame0 returns different type names from get vs what create accepts.
|
||||||
|
# Map internal types back to create-API types.
|
||||||
|
TYPE_TO_CREATE = {
|
||||||
|
"Box": "Rectangle",
|
||||||
|
"box": "Rectangle",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def topo_sort_shapes(shapes):
|
||||||
|
"""Sort shape IDs so parents come before children."""
|
||||||
|
order = []
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
def visit(sid):
|
||||||
|
if sid in visited:
|
||||||
|
return
|
||||||
|
visited.add(sid)
|
||||||
|
shape = shapes[sid]
|
||||||
|
parent = shape.get("parent")
|
||||||
|
if parent and parent in shapes:
|
||||||
|
visit(parent)
|
||||||
|
order.append(sid)
|
||||||
|
|
||||||
|
for sid in shapes:
|
||||||
|
visit(sid)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_page(port, name, mapping):
|
||||||
|
"""Find existing page by mapping or name, or create a new one."""
|
||||||
|
# Try mapped page_id first
|
||||||
|
if mapping.get("page_id"):
|
||||||
|
try:
|
||||||
|
page = api(port, "page:get", {"pageId": mapping["page_id"]})
|
||||||
|
if page:
|
||||||
|
return mapping["page_id"]
|
||||||
|
except SystemExit:
|
||||||
|
pass # Page no longer exists, fall through
|
||||||
|
|
||||||
|
# Search by name in existing pages
|
||||||
|
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||||
|
if doc and "children" in doc:
|
||||||
|
for page in doc["children"]:
|
||||||
|
if page.get("name") == name:
|
||||||
|
return page["id"]
|
||||||
|
|
||||||
|
# Create new page
|
||||||
|
page = api(port, "page:add", {"pageProps": {"name": name}})
|
||||||
|
return page["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def clear_page(port, page_id):
|
||||||
|
"""Delete all shapes on a page."""
|
||||||
|
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||||
|
if not page or "children" not in page:
|
||||||
|
return
|
||||||
|
shape_ids = [s["id"] for s in page["children"]]
|
||||||
|
if shape_ids:
|
||||||
|
api(port, "edit:delete", {"shapeIdArray": shape_ids})
|
||||||
|
|
||||||
|
|
||||||
|
def push(wireframe_path, port):
|
||||||
|
"""Push local wireframe JSON to Frame0."""
|
||||||
|
with open(wireframe_path) as f:
|
||||||
|
wireframe = json.load(f)
|
||||||
|
|
||||||
|
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
|
||||||
|
shapes = wireframe.get("shapes", {})
|
||||||
|
connectors = wireframe.get("connectors", {})
|
||||||
|
|
||||||
|
mapping = load_mapping(wireframe_path)
|
||||||
|
|
||||||
|
# Find or create page
|
||||||
|
page_id = find_or_create_page(port, name, mapping)
|
||||||
|
mapping["page_id"] = page_id
|
||||||
|
|
||||||
|
# Switch to page and clear it
|
||||||
|
api(port, "page:set-current-page", {"pageId": page_id})
|
||||||
|
clear_page(port, page_id)
|
||||||
|
|
||||||
|
# Reset ID mappings (shapes are recreated)
|
||||||
|
mapping["shapes"] = {}
|
||||||
|
mapping["connectors"] = {}
|
||||||
|
|
||||||
|
# Create shapes in dependency order
|
||||||
|
order = topo_sort_shapes(shapes)
|
||||||
|
for local_id in order:
|
||||||
|
shape = shapes[local_id]
|
||||||
|
raw_type = shape.get("type", "Rectangle")
|
||||||
|
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
|
||||||
|
parent_local = shape.get("parent")
|
||||||
|
|
||||||
|
# Build shapeProps from everything except meta fields
|
||||||
|
meta_keys = {"type", "parent"}
|
||||||
|
props = {k: v for k, v in shape.items() if k not in meta_keys}
|
||||||
|
|
||||||
|
# Set name to local_id if not explicitly set
|
||||||
|
if "name" not in props:
|
||||||
|
props["name"] = local_id
|
||||||
|
|
||||||
|
create_args = {
|
||||||
|
"type": shape_type,
|
||||||
|
"shapeProps": props,
|
||||||
|
"convertColors": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve parent ID
|
||||||
|
if parent_local and parent_local in mapping["shapes"]:
|
||||||
|
create_args["parentId"] = mapping["shapes"][parent_local]
|
||||||
|
|
||||||
|
f0_id = api(port, "shape:create-shape", create_args)
|
||||||
|
mapping["shapes"][local_id] = f0_id
|
||||||
|
|
||||||
|
# Create connectors
|
||||||
|
for local_id, conn in connectors.items():
|
||||||
|
tail_local = conn.get("tailId")
|
||||||
|
head_local = conn.get("headId")
|
||||||
|
|
||||||
|
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
|
||||||
|
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta_keys = {"tailId", "headId"}
|
||||||
|
props = {k: v for k, v in conn.items() if k not in meta_keys}
|
||||||
|
if "name" not in props:
|
||||||
|
props["name"] = local_id
|
||||||
|
|
||||||
|
f0_id = api(port, "shape:create-connector", {
|
||||||
|
"tailId": mapping["shapes"][tail_local],
|
||||||
|
"headId": mapping["shapes"][head_local],
|
||||||
|
"shapeProps": props,
|
||||||
|
"convertColors": True,
|
||||||
|
})
|
||||||
|
mapping["connectors"][local_id] = f0_id
|
||||||
|
|
||||||
|
# Fit to screen
|
||||||
|
api(port, "view:fit-to-screen")
|
||||||
|
|
||||||
|
save_mapping(wireframe_path, mapping)
|
||||||
|
total = len(mapping["shapes"]) + len(mapping["connectors"])
|
||||||
|
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Pull: Frame0 -> local JSON -----------------------------------------------
|
||||||
|
|
||||||
|
def pull(page_ref, output_path, port):
|
||||||
|
"""Pull a Frame0 page into local wireframe JSON."""
|
||||||
|
# Resolve page_ref: could be an ID or a name
|
||||||
|
page_id = None
|
||||||
|
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||||
|
if doc and "children" in doc:
|
||||||
|
for page in doc["children"]:
|
||||||
|
if page["id"] == page_ref or page.get("name") == page_ref:
|
||||||
|
page_id = page["id"]
|
||||||
|
page_name = page.get("name", page_ref)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not page_id:
|
||||||
|
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load existing mapping for reverse lookup
|
||||||
|
mapping = load_mapping(output_path)
|
||||||
|
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
|
||||||
|
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
|
||||||
|
|
||||||
|
# Get full page with shapes
|
||||||
|
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||||
|
|
||||||
|
shapes = {}
|
||||||
|
connectors = {}
|
||||||
|
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
|
||||||
|
auto_id_counter = [0]
|
||||||
|
|
||||||
|
def auto_id(f0_shape):
|
||||||
|
"""Generate a stable local ID from shape name or auto-number."""
|
||||||
|
# Prefer existing mapping
|
||||||
|
f0_id = f0_shape["id"]
|
||||||
|
if f0_id in reverse_map:
|
||||||
|
return reverse_map[f0_id]
|
||||||
|
# Use sanitized name
|
||||||
|
name = f0_shape.get("name", "")
|
||||||
|
if name:
|
||||||
|
sanitized = name.lower().replace(" ", "-").replace("_", "-")
|
||||||
|
if sanitized not in shapes:
|
||||||
|
return sanitized
|
||||||
|
# Fallback: auto-number
|
||||||
|
auto_id_counter[0] += 1
|
||||||
|
return f"s{auto_id_counter[0]:03d}"
|
||||||
|
|
||||||
|
def process_shape(f0_shape, parent_local_id=None):
|
||||||
|
f0_id = f0_shape["id"]
|
||||||
|
local_id = auto_id(f0_shape)
|
||||||
|
new_mapping["shapes"][local_id] = f0_id
|
||||||
|
|
||||||
|
# Extract shape properties — only strip structural keys that our
|
||||||
|
# ID mapping replaces. Everything else passes through as-is so the
|
||||||
|
# local JSON speaks Frame0's native vocabulary.
|
||||||
|
shape_type = f0_shape.get("type", "Box")
|
||||||
|
skip_keys = {"id", "type", "children", "pageId", "parentId"}
|
||||||
|
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
|
||||||
|
|
||||||
|
entry = {"type": shape_type}
|
||||||
|
if parent_local_id:
|
||||||
|
entry["parent"] = parent_local_id
|
||||||
|
entry.update(props)
|
||||||
|
|
||||||
|
# Remove name if it matches local_id (redundant)
|
||||||
|
if entry.get("name") == local_id:
|
||||||
|
del entry["name"]
|
||||||
|
|
||||||
|
shapes[local_id] = entry
|
||||||
|
|
||||||
|
# Process children recursively
|
||||||
|
for child in f0_shape.get("children", []):
|
||||||
|
child_type = child.get("type", "")
|
||||||
|
if child_type == "Connector":
|
||||||
|
process_connector(child)
|
||||||
|
else:
|
||||||
|
process_shape(child, local_id)
|
||||||
|
|
||||||
|
def process_connector(f0_conn):
|
||||||
|
f0_id = f0_conn["id"]
|
||||||
|
local_id = reverse_conn.get(f0_id)
|
||||||
|
if not local_id:
|
||||||
|
auto_id_counter[0] += 1
|
||||||
|
local_id = f"c{auto_id_counter[0]:03d}"
|
||||||
|
|
||||||
|
new_mapping["connectors"][local_id] = f0_id
|
||||||
|
|
||||||
|
tail_f0 = f0_conn.get("tail", {}).get("id")
|
||||||
|
head_f0 = f0_conn.get("head", {}).get("id")
|
||||||
|
|
||||||
|
entry = {}
|
||||||
|
if tail_f0:
|
||||||
|
# Will be resolved after all shapes are processed
|
||||||
|
entry["_tailF0"] = tail_f0
|
||||||
|
if head_f0:
|
||||||
|
entry["_headF0"] = head_f0
|
||||||
|
|
||||||
|
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
|
||||||
|
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
|
||||||
|
entry.update(props)
|
||||||
|
|
||||||
|
connectors[local_id] = entry
|
||||||
|
|
||||||
|
# Process all top-level shapes
|
||||||
|
for child in page.get("children", []):
|
||||||
|
child_type = child.get("type", "")
|
||||||
|
if child_type == "Connector":
|
||||||
|
process_connector(child)
|
||||||
|
else:
|
||||||
|
process_shape(child)
|
||||||
|
|
||||||
|
# Resolve connector references to local IDs
|
||||||
|
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
|
||||||
|
for conn in connectors.values():
|
||||||
|
tail_f0 = conn.pop("_tailF0", None)
|
||||||
|
head_f0 = conn.pop("_headF0", None)
|
||||||
|
if tail_f0 and tail_f0 in f0_to_local:
|
||||||
|
conn["tailId"] = f0_to_local[tail_f0]
|
||||||
|
if head_f0 and head_f0 in f0_to_local:
|
||||||
|
conn["headId"] = f0_to_local[head_f0]
|
||||||
|
|
||||||
|
wireframe = {"name": page_name}
|
||||||
|
if shapes:
|
||||||
|
wireframe["shapes"] = shapes
|
||||||
|
if connectors:
|
||||||
|
wireframe["connectors"] = connectors
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
json.dump(wireframe, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
save_mapping(output_path, new_mapping)
|
||||||
|
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Export: push then export as image -----------------------------------------
|
||||||
|
|
||||||
|
def export_image(wireframe_path, output_path, port, fmt="image/png"):
|
||||||
|
"""Push wireframe to Frame0 and export the page as an image."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Push first to ensure Frame0 is up to date
|
||||||
|
push(wireframe_path, port)
|
||||||
|
|
||||||
|
mapping = load_mapping(wireframe_path)
|
||||||
|
page_id = mapping.get("page_id")
|
||||||
|
if not page_id:
|
||||||
|
print("ERROR: No page_id in mapping after push", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
image_b64 = api(port, "file:export-image", {
|
||||||
|
"pageId": page_id,
|
||||||
|
"format": fmt,
|
||||||
|
"fillBackground": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
image_bytes = base64.b64decode(image_b64)
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
|
||||||
|
|
||||||
|
|
||||||
|
# -- CLI -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
|
||||||
|
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
|
||||||
|
p_push.add_argument("wireframe", help="Path to wireframe .json file")
|
||||||
|
|
||||||
|
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
|
||||||
|
p_pull.add_argument("page", help="Page ID or page name")
|
||||||
|
p_pull.add_argument("output", help="Output .json path")
|
||||||
|
|
||||||
|
p_export = sub.add_parser("export", help="Push and export as image")
|
||||||
|
p_export.add_argument("wireframe", help="Path to wireframe .json file")
|
||||||
|
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
|
||||||
|
p_export.add_argument("--format", default="image/png",
|
||||||
|
help="Export MIME type (default: image/png)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "push":
|
||||||
|
push(args.wireframe, args.port)
|
||||||
|
elif args.command == "pull":
|
||||||
|
pull(args.page, args.output, args.port)
|
||||||
|
elif args.command == "export":
|
||||||
|
export_image(args.wireframe, args.output, args.port, args.format)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
|
|||||||
|
|
||||||
## Staging Rules
|
## Staging Rules
|
||||||
|
|
||||||
- Stage files by name — never use `git add -A` or `git add .`
|
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
|
||||||
- Verify no secrets, saves, or binary blobs are staged
|
These apply to ALL git operations, not just this skill.
|
||||||
- Skip files in `.gitignore`
|
|
||||||
- The `.claude/` directory IS tracked — skills belong in the repo
|
|
||||||
|
|||||||
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
|
|||||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > /tmp/pr-body.md << 'EOF'
|
|
||||||
## Summary
|
|
||||||
...
|
|
||||||
EOF
|
|
||||||
tea pr create \
|
tea pr create \
|
||||||
--repo jpmschweitzer/settled-reach \
|
--repo jpmschweitzer/settled-reach \
|
||||||
--login schweitz \
|
--login schweitz \
|
||||||
--title "<title>" \
|
--title "<title>" \
|
||||||
--description "$(cat /tmp/pr-body.md)" \
|
--description "## Summary ..." \
|
||||||
--base main \
|
--base main \
|
||||||
--head <branch>
|
--head <branch>
|
||||||
```
|
```
|
||||||
@@ -134,6 +130,14 @@ Report which tickets were moved to review. Skip tickets that are
|
|||||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||||
`in_progress` → `review`).
|
`in_progress` → `review`).
|
||||||
|
|
||||||
|
### 9. Next steps
|
||||||
|
|
||||||
|
If a sprint team is active (you are the team lead), do NOT shut down
|
||||||
|
agents after pushing. The team should remain alive for PR review and
|
||||||
|
potential comment fixes.
|
||||||
|
|
||||||
|
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
|
||||||
|
|
||||||
## Arguments
|
## Arguments
|
||||||
|
|
||||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ on the branch type. All reviewers must approve for a clean review.
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### 1. Determine the branch
|
### 0. Branch guard — MUST be on `main`
|
||||||
|
|
||||||
If the user provided a branch name as argument, use it. Otherwise use the
|
```bash
|
||||||
current branch (`git branch --show-current`). If on `main`, ask the user
|
git branch --show-current
|
||||||
which branch to review.
|
```
|
||||||
|
|
||||||
|
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||||
|
"PR reviews must be run from the `main` worktree. Switch to `main` first."
|
||||||
|
Do NOT proceed with the review from a team branch.
|
||||||
|
|
||||||
|
### 1. Determine the branch to review
|
||||||
|
|
||||||
|
If the user provided a branch name as argument, use it. Otherwise list open
|
||||||
|
PRs and ask the user which branch to review.
|
||||||
|
|
||||||
To list open PRs on Gitea:
|
To list open PRs on Gitea:
|
||||||
```bash
|
```bash
|
||||||
@@ -63,23 +72,44 @@ If the diff is empty, report "No changes to review" and stop.
|
|||||||
|
|
||||||
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||||
2. Read source files directly from the branch:
|
2. Read source files directly from the branch worktree (see below).
|
||||||
`git show origin/<branch>:<path>`
|
|
||||||
|
|
||||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||||
raw diff to reviewers — cleaner context, better reviews. Read files with
|
raw diff to reviewers — cleaner context, better reviews.
|
||||||
`git show origin/<branch>:<path>` and include them in the prompt.
|
|
||||||
|
|
||||||
**IMPORTANT — agent tool access:** Not all reviewer agents have Bash access.
|
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
||||||
Agents that CAN read from branches themselves: **Hoshe, Tyre, Araminta**.
|
worktrees. Each team branch is checked out at:
|
||||||
Agents that CANNOT (no Bash tool): **Paula, Miri, Ozzie, Gestalt, Gore, Nigel**.
|
|
||||||
|
|
||||||
For agents without Bash, you MUST read the source files yourself (via
|
```
|
||||||
`git show origin/<branch>:<path>`) and **paste the file contents directly
|
/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||||
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
|
For example, the `copy` branch lives at:
|
||||||
`decisions/*.md` files these agents need for context.
|
```
|
||||||
|
/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**All reviewer agents** (regardless of Bash access) should read source files
|
||||||
|
from the worktree path using the Read tool. This is more reliable than
|
||||||
|
`git show origin/<branch>:<path>` because:
|
||||||
|
- All agents have Read access (no Bash dependency)
|
||||||
|
- Files are always the actual branch checkout (no stale cache)
|
||||||
|
- No risk of accidentally reading from main's working directory
|
||||||
|
|
||||||
|
When constructing reviewer prompts, tell agents to read files from the
|
||||||
|
worktree path. Example instruction for agents:
|
||||||
|
|
||||||
|
```
|
||||||
|
Read the changed files from the branch worktree. The branch is checked
|
||||||
|
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||||
|
|
||||||
|
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||||
|
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||||
|
worktree (they're identical to main, but using the worktree path keeps
|
||||||
|
agents grounded in the correct directory).
|
||||||
|
|
||||||
### 4. Spawn reviewers in parallel
|
### 4. Spawn reviewers in parallel
|
||||||
|
|
||||||
@@ -153,19 +183,11 @@ After presenting results to the user, post the review as a PR comment.
|
|||||||
|
|
||||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||||
|
|
||||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
Post using the `tea-comment` wrapper (handles temp files and cleanup):
|
||||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tooling/tea-comment <PR_NUMBER> "review markdown here"
|
||||||
```
|
```
|
||||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
|
||||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
|
||||||
|
|
||||||
# Step 2: Post to Gitea (separate Bash call)
|
|
||||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
|
||||||
directory is gitignored and exists in the repo root for this purpose.
|
|
||||||
|
|
||||||
## 7. Merging approved PRs
|
## 7. Merging approved PRs
|
||||||
|
|
||||||
@@ -182,6 +204,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
|||||||
Gitea does **not** auto-close PRs when you push a local merge — always close
|
Gitea does **not** auto-close PRs when you push a local merge — always close
|
||||||
manually with `tea pr close` after pushing.
|
manually with `tea pr close` after pushing.
|
||||||
|
|
||||||
|
### 8. Post-review team actions
|
||||||
|
|
||||||
|
If a sprint team is active and you are the team lead, handle the
|
||||||
|
review outcome:
|
||||||
|
|
||||||
|
**CHANGES_REQUESTED:**
|
||||||
|
The sprint-start lifecycle (step 9c) handles dispatching review
|
||||||
|
comments to agents. After presenting results, remind the lead:
|
||||||
|
"Review requested changes. Create tasks from the warnings/critical
|
||||||
|
issues and dispatch to idle agents, then re-push and re-review."
|
||||||
|
|
||||||
|
**APPROVED:**
|
||||||
|
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||||
|
presenting results, remind the lead: "Review approved. Proceed with
|
||||||
|
team shutdown per sprint-start step 9c."
|
||||||
|
|
||||||
## Tips from practice
|
## Tips from practice
|
||||||
|
|
||||||
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
|
|
||||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||||
|
|
||||||
|
**All agents read from worktree paths.** Each branch is checked out at:
|
||||||
|
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
|
||||||
|
|
||||||
|
Tell every reviewer agent to read source files from the worktree using the
|
||||||
|
Read tool. Include the worktree base path and a list of changed files in
|
||||||
|
every prompt. Do NOT rely on `git show` or paste file contents — agents
|
||||||
|
can read directly from the worktree.
|
||||||
|
|
||||||
## Code reviews (`server`, `client`, `ci`)
|
## Code reviews (`server`, `client`, `ci`)
|
||||||
|
|
||||||
**Hoshe (Code Quality)**
|
**Hoshe (Code Quality)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||||
- Prompt: Include source code and commit log. Ask Hoshe to review for:
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
|
Tell Hoshe to read source files from the worktree, then review for:
|
||||||
- Correctness and bug risks
|
- Correctness and bug risks
|
||||||
- Error handling gaps
|
- Error handling gaps
|
||||||
- Test coverage (are new features tested?)
|
- Test coverage (are new features tested?)
|
||||||
@@ -16,44 +25,43 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
|||||||
|
|
||||||
**Tyre (Architecture)**
|
**Tyre (Architecture)**
|
||||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||||
- Prompt: Include source code and commit log. Tell Tyre to read the relevant
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
`decisions/*.md` files first, then review for:
|
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
|
||||||
|
first, then review for:
|
||||||
- Architectural consistency with project decisions
|
- Architectural consistency with project decisions
|
||||||
- API/interface design quality
|
- API/interface design quality
|
||||||
- Dependency and coupling concerns
|
- Dependency and coupling concerns
|
||||||
- Scalability implications
|
- Scalability implications
|
||||||
- Whether the change respects non-negotiable baselines (D-010, D-012)
|
- 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`)
|
## Copy reviews (`copy`)
|
||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
|
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||||
- Formatting consistency (markdown, file naming, frontmatter)
|
- Formatting consistency (markdown, file naming, frontmatter)
|
||||||
- Broken references or links
|
- Broken references or links
|
||||||
- Spelling and grammar
|
- Spelling and grammar
|
||||||
- File organization and structure
|
- File organization and structure
|
||||||
- Missing or orphaned files
|
- Missing or orphaned files
|
||||||
|
|
||||||
**Paula (Narrative Depth)** — NO BASH ACCESS
|
**Paula (Narrative Depth)**
|
||||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||||
- Paula cannot read from branches. You must paste file contents and decision
|
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||||
files directly into the prompt.
|
list of relevant `decisions/*.md` files to read. Tell Paula to read all
|
||||||
- Prompt: Include full text of changed files, commit log, and relevant
|
files from the worktree using the Read tool, then review for:
|
||||||
`decisions/*.md` content. Ask Paula to review for:
|
|
||||||
- Narrative quality and character voice consistency
|
- Narrative quality and character voice consistency
|
||||||
- Whether dialogue and monologue feel authentic to the characters
|
- Whether dialogue and monologue feel authentic to the characters
|
||||||
- Consequences and stakes — do choices carry weight?
|
- Consequences and stakes — do choices carry weight?
|
||||||
- Political and interpersonal depth
|
- Political and interpersonal depth
|
||||||
- Emotional resonance — does the text make you feel something?
|
- Emotional resonance — does the text make you feel something?
|
||||||
|
|
||||||
**Miri (World Consistency)** — NO BASH ACCESS
|
**Miri (World Consistency)**
|
||||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||||
- Miri cannot read from branches. You must paste file contents and decision
|
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||||
files directly into the prompt.
|
list of relevant `decisions/*.md` files to read. Tell Miri to read all
|
||||||
- Prompt: Include full text of changed files, commit log, and relevant
|
files from the worktree using the Read tool, then review for:
|
||||||
`decisions/*.md` content. Ask Miri to review for:
|
|
||||||
- Lore accuracy — do facts match established setting?
|
- Lore accuracy — do facts match established setting?
|
||||||
- Internal consistency across files
|
- Internal consistency across files
|
||||||
- IP originality — nothing should read as a copy from another franchise
|
- IP originality — nothing should read as a copy from another franchise
|
||||||
@@ -64,16 +72,18 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
|||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
|
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||||
- File format and naming conventions
|
- File format and naming conventions
|
||||||
- Asset organization and directory structure
|
- Asset organization and directory structure
|
||||||
- Missing or broken references in scene/resource files
|
- Missing or broken references in scene/resource files
|
||||||
- Import settings consistency
|
- Import settings consistency
|
||||||
|
|
||||||
**Araminta (Art Direction)** — HAS BASH ACCESS
|
**Araminta (Art Direction)**
|
||||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||||
- Prompt: Include the changed files and commit log. Tell Araminta to read
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
the style guide and relevant design docs first, then review for:
|
Tell Araminta to read the style guide and relevant design docs from the
|
||||||
|
worktree first, then review for:
|
||||||
- Visual consistency with the established style guide
|
- Visual consistency with the established style guide
|
||||||
- Color palette adherence
|
- Color palette adherence
|
||||||
- UI pattern consistency (diegetic-first, clarity over beauty)
|
- UI pattern consistency (diegetic-first, clarity over beauty)
|
||||||
@@ -84,17 +94,17 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
|||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
|
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||||
- File format and naming conventions
|
- File format and naming conventions
|
||||||
- Audio asset organization and directory structure
|
- Audio asset organization and directory structure
|
||||||
- Missing or broken references
|
- Missing or broken references
|
||||||
- Import/bus configuration consistency
|
- Import/bus configuration consistency
|
||||||
|
|
||||||
**Ozzie (Player Experience)** — NO BASH ACCESS
|
**Ozzie (Player Experience)**
|
||||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||||
- Ozzie cannot read from branches. You must paste file contents directly
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
into the prompt.
|
Tell Ozzie to read all files from the worktree using the Read tool, then
|
||||||
- Prompt: Include full text of changed files and commit log. Ask Ozzie to
|
|
||||||
review for:
|
review for:
|
||||||
- Emotional impact — does the audio enhance the moment?
|
- Emotional impact — does the audio enhance the moment?
|
||||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||||
|
|||||||
@@ -325,3 +325,57 @@ Output to the user:
|
|||||||
You are now the team lead. Agents work autonomously — monitor via
|
You are now the team lead. Agents work autonomously — monitor via
|
||||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||||
they arise.
|
they arise.
|
||||||
|
|
||||||
|
**When all tasks complete:** Do NOT shut down agents. The team stays
|
||||||
|
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
|
||||||
|
|
||||||
|
### 9. Post-work lifecycle
|
||||||
|
|
||||||
|
When all tasks are complete (TaskList shows all completed):
|
||||||
|
|
||||||
|
#### 9a. Commit and push
|
||||||
|
|
||||||
|
Run `/git-commit` to commit all changes, then `/pr-push` to create or
|
||||||
|
update the PR. Do NOT shut down agents — the team stays alive for review.
|
||||||
|
|
||||||
|
#### 9b. Review
|
||||||
|
|
||||||
|
Run `/pr-review` to spawn temporary reviewers. Wait for results.
|
||||||
|
|
||||||
|
#### 9c. Handle review outcome
|
||||||
|
|
||||||
|
**If CHANGES_REQUESTED:**
|
||||||
|
|
||||||
|
1. Parse the review comment table (from the Gitea PR comment or the
|
||||||
|
review output). Extract each warning/critical issue with:
|
||||||
|
- File path and approximate line
|
||||||
|
- Severity (critical / warning / suggestion)
|
||||||
|
- Description
|
||||||
|
|
||||||
|
2. Create a task per warning/critical issue:
|
||||||
|
```
|
||||||
|
TaskCreate(
|
||||||
|
subject: "Review: {short description}",
|
||||||
|
description: "{full issue description from review table, including
|
||||||
|
file path, severity, and reviewer name}",
|
||||||
|
activeForm: "Fixing review comment: {short description}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
Skip suggestion-severity items unless they are trivial (1-line fixes).
|
||||||
|
|
||||||
|
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||||
|
them to check TaskList for new review-fix tasks. Agents claim and
|
||||||
|
work tasks as usual.
|
||||||
|
|
||||||
|
4. After all review-fix tasks are complete, re-run `/git-commit` then
|
||||||
|
`/pr-push` to update the PR. Then re-run `/pr-review`.
|
||||||
|
|
||||||
|
5. Repeat this loop until review returns APPROVED.
|
||||||
|
|
||||||
|
**If APPROVED:**
|
||||||
|
|
||||||
|
1. Send `shutdown_request` to all sprint agents.
|
||||||
|
2. Wait for all `shutdown_response` confirmations.
|
||||||
|
3. Call `TeamDelete` to clean up.
|
||||||
|
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
|
||||||
|
merge on main."
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: sprint-status
|
||||||
|
description: >
|
||||||
|
Sprint health check and cleanup sweep. Lists all tickets in the active
|
||||||
|
sprint grouped by status, detects bookkeeping issues (stale tickets,
|
||||||
|
orphan PRs, done-but-open PRs, unassigned work), and shows open work
|
||||||
|
by team. Use when checking sprint progress, before sprint close, or
|
||||||
|
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||||
|
sweep", "what's open", "sprint health".
|
||||||
|
user-invocable: true
|
||||||
|
allowed-tools: Task, Read, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sprint Status
|
||||||
|
|
||||||
|
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||||
|
|
||||||
|
When this skill is invoked, spawn a subagent using the Task tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task(
|
||||||
|
subagent_type: "general-purpose",
|
||||||
|
model: "haiku",
|
||||||
|
prompt: "Run /sprint-status. Read the skill at
|
||||||
|
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||||
|
(below the --- separator), then execute it.",
|
||||||
|
description: "Sprint status report"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Present the subagent's output to the user verbatim. Do NOT run the
|
||||||
|
workflow yourself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The remainder of this file is the subagent's reference for executing
|
||||||
|
the workflow.
|
||||||
|
|
||||||
|
## Step 1 — Gather data
|
||||||
|
|
||||||
|
Run these two commands in parallel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
db/connectors/sprint sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||||
|
```
|
||||||
|
|
||||||
|
The `sweep` command returns JSON with:
|
||||||
|
- `sprint` — id, name, goal
|
||||||
|
- `progress` — total, done, pct
|
||||||
|
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
|
||||||
|
- `by_team` — per-team counts
|
||||||
|
- `issues` — bookkeeping problems with suggested fix commands
|
||||||
|
|
||||||
|
The `tea pr list` returns open PRs as `#N title` lines.
|
||||||
|
|
||||||
|
## Step 2 — Cross-reference PRs with tickets
|
||||||
|
|
||||||
|
Parse PR head branches from the `tea pr list` output. Known team branches:
|
||||||
|
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||||
|
|
||||||
|
Detect additional issues:
|
||||||
|
|
||||||
|
- **done_team_open_pr**: A team's tickets are all done but an open PR
|
||||||
|
still exists for that team branch.
|
||||||
|
- **orphan_pr**: An open PR exists on a branch that has no tickets in
|
||||||
|
the active sprint.
|
||||||
|
|
||||||
|
Add these to the issues list from step 1.
|
||||||
|
|
||||||
|
## Step 3 — Format output
|
||||||
|
|
||||||
|
Read `references/output-template.md` for the exact format spec.
|
||||||
|
|
||||||
|
Render the report using data from steps 1-2. Key rules:
|
||||||
|
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
|
||||||
|
- Sort tickets within sections by team then ticket ID
|
||||||
|
- Empty sections: show header with "(0)" and "(none)" — no empty table
|
||||||
|
- Bookkeeping Issues: two-column table (Issue, Fix)
|
||||||
|
- Open Work by Team: summary table at the bottom
|
||||||
|
- Issue type labels: `stale_backlog` → "Stale backlog",
|
||||||
|
`unassigned_in_progress` → "Unassigned in_progress",
|
||||||
|
`assigned_but_done` → "Assigned but done",
|
||||||
|
`done_team_open_pr` → "Done team with open PR",
|
||||||
|
`orphan_pr` → "Orphan PR"
|
||||||
|
|
||||||
|
## Step 4 — Suggest actions
|
||||||
|
|
||||||
|
After the formatted report, if there are bookkeeping issues, add a
|
||||||
|
"Suggested fixes" section with the fix command for each issue. Group
|
||||||
|
by issue type for readability.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Sprint Status Output Template
|
||||||
|
|
||||||
|
## Sprint {N}: {Theme} — Status Report
|
||||||
|
|
||||||
|
**Goal:** {goal}
|
||||||
|
**Status:** {status} | {done}/{total} tickets ({pct}%)
|
||||||
|
**Open PRs:** {count} ({branches})
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Completed ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Assigned |
|
||||||
|
|---|------|-------|----------|
|
||||||
|
| #{id} | {team} | {title} | {assigned} |
|
||||||
|
|
||||||
|
### In Review ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | PR |
|
||||||
|
|---|------|-------|----|
|
||||||
|
| #{id} | {team} | {title} | #{pr} |
|
||||||
|
|
||||||
|
### In Progress ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Assigned | Note |
|
||||||
|
|---|------|-------|----------|------|
|
||||||
|
| #{id} | {team} | {title} | {assigned} | |
|
||||||
|
|
||||||
|
### Blocked ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Blocked by |
|
||||||
|
|---|------|-------|------------|
|
||||||
|
| #{id} | {team} | {title} | #{ids} |
|
||||||
|
|
||||||
|
### Backlog ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Note |
|
||||||
|
|---|------|-------|----|
|
||||||
|
| #{id} | {team} | {title} | not started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bookkeeping Issues
|
||||||
|
|
||||||
|
| Issue | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| {type}: {detail} | `{command}` |
|
||||||
|
|
||||||
|
### Open Work by Team
|
||||||
|
|
||||||
|
| Team | Backlog | In Progress | Review | Blocked | Done |
|
||||||
|
|------|---------|-------------|--------|---------|------|
|
||||||
|
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||||
|
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||||
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
|
|||||||
|
|
||||||
# Ticket Skill
|
# Ticket Skill
|
||||||
|
|
||||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
|
||||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
section. This skill covers the full command reference.
|
||||||
This skill covers the full command reference.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ When all Round N tasks are complete:
|
|||||||
|
|
||||||
Wrap-up sequence:
|
Wrap-up sequence:
|
||||||
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
|
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
|
||||||
2. If SI is present, SI creates tickets from decided items
|
2. Qatux creates or updates diagrams (via `/d2-diagram`) for any new D-records produced by the workshop
|
||||||
3. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
|
3. If SI is present, SI creates tickets from decided items
|
||||||
4. TeamDelete to clean up
|
4. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
|
||||||
|
5. TeamDelete to clean up
|
||||||
|
|
||||||
## Agent Type Reference
|
## Agent Type Reference
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ renderer/output/*.png
|
|||||||
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
||||||
db/commonwealth.db*
|
db/commonwealth.db*
|
||||||
|
|
||||||
|
# Frame0 ID mapping files (ephemeral, per-machine)
|
||||||
|
*.idmap.json
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -6,6 +6,107 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
|
||||||
|
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
|
||||||
|
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
|
||||||
|
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
||||||
|
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
||||||
|
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||||
|
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||||
|
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||||
|
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
|
||||||
|
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
|
||||||
|
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
|
||||||
|
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
|
||||||
|
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
|
||||||
|
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
|
||||||
|
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
|
||||||
|
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
|
||||||
|
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||||
|
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||||
|
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||||
|
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||||
|
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||||
|
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||||
|
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
|
||||||
|
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
|
||||||
|
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
|
||||||
|
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||||
|
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||||
|
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||||
|
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
|
||||||
|
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
|
||||||
|
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||||
|
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||||
|
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||||
|
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||||
|
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
|
||||||
|
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
|
||||||
|
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
|
||||||
|
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
|
||||||
|
- Sprint 16 closed (8/8 done)
|
||||||
|
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
|
||||||
|
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
|
||||||
|
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
|
||||||
|
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
|
||||||
|
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
|
||||||
|
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
|
||||||
|
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||||
|
- DialogueResponse cross-language fixture for GDScript testing
|
||||||
|
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||||
|
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||||
|
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||||
|
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||||
|
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||||
|
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||||
|
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||||
|
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
|
||||||
|
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
|
||||||
|
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
|
||||||
|
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
|
||||||
|
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
|
||||||
|
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
|
||||||
|
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
|
||||||
|
- Weighted selection fallback replaced with unreachable!() — dead code removed
|
||||||
|
- assert!(false) → panic!() in serialization tests (clippy)
|
||||||
|
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||||
|
|
||||||
|
## [v0.1.15] — 2026-02-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Sprint 16 "Converse" briefings — 8 tickets across server/client/copy/visual teams
|
||||||
|
- 19 UI wireframes — HUD, dialogue, monologue, popups, menus in v0.1 and v1.0 variants with D-record cross-references
|
||||||
|
- d2-diagram skill — text-to-diagram generation with project defaults (theme 200, dagre, PNG)
|
||||||
|
- frame0-wireframe skill — UI wireframing via Frame0 HTTP API, replaces MCP dependency with bash+curl
|
||||||
|
- 16 decision diagrams — architecture, data-flow, entity, state, and UI categories covering all project decisions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- frame0-wireframe skill rewritten — JSON-as-truth workflow with frame0-sync.py, batch export, renderer-only guidance
|
||||||
|
- pr-review skill — all reviewer agents now use worktree paths instead of git show
|
||||||
|
- Dialogue panel is always visible as permanent insert UI element (D-061)
|
||||||
|
- Makefile: check-protocol target verifies server/client protocol versions match before build
|
||||||
|
- D-035 amended: line ID namespace changed from location-scoped to NPC-scoped (Sprint 15)
|
||||||
|
- Tilemap z-layer filtering — FloorTiles renders z=0 only, z=1/z>1 reserved for future layer nodes (#71, D-049)
|
||||||
|
- Entity 24x32 footprint per D-044 visual hierarchy — split ENTITY_SIZE into WIDTH/HEIGHT with separate offsets (#72)
|
||||||
|
- Follow target stub on GameState — `follow_target_id` field ready for server #241 Follow verb
|
||||||
|
- Manual exponential camera smoothing — CAMERA_SMOOTHING_SPEED constant (8.0), same lerp pattern as entity renderer (#117)
|
||||||
|
- 31 new Sprint 15 validation tests — camera smoothing, UI framework z-layers, entity footprint, Sprint 14 regressions
|
||||||
|
- SpatialIndex trait with naive Vec implementation — entities_in_range, entities_at, update methods with Manhattan distance (#340)
|
||||||
|
- NPC generation pipeline — procedural seeding of all 10 D-024 axes via SimRng with constraint validation (#92)
|
||||||
|
- Personality and tell system — 5 tell categories (Nervous, Angry, Friendly, Guarded, RoutineDeviation) derived from NPC axis values each tick (#90)
|
||||||
|
- Tolerance threshold monitoring — ToleranceBreachEvent on stress exceeding per-NPC threshold, mood FSM integration (#105)
|
||||||
|
- Routine deviation detection — RoutineDeviationEvent on wrong location/activity for day phase, absence detection, pathfinding-aware (#243)
|
||||||
|
- Follow mechanic — Follow verb, proximity/LOS tracking, double-frequency observation events, NPC suspicion accumulation, configurable thresholds (#241)
|
||||||
|
- Monologue event triggers — observe_npc, hear_sound, observe_anomaly, witness_interaction, post_conversation with D-035 context tags (#119)
|
||||||
|
- Protocol v13 — tell_state on VisibleEntity, follow_state on ObserverSnapshot, Follow verb
|
||||||
|
|
||||||
## [v0.1.14] — 2026-02-21
|
## [v0.1.14] — 2026-02-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
# The Settled Reach
|
# The Settled Reach
|
||||||
|
|
||||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
|
||||||
|
|
||||||
**Official Title:** The Settled Reach (D-021)
|
**Official Title:** The Settled Reach
|
||||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
**Repository name:** settled-reach
|
||||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
client/ # Godot 4 client (D-020)
|
client/ # Godot 4 client
|
||||||
server/ # Rust/bevy_ecs simulation server (D-020)
|
server/ # Rust/bevy_ecs simulation server
|
||||||
tooling/ # Build tools, scripts, asset pipelines
|
tooling/ # Build tools, scripts, asset pipelines
|
||||||
tests/ # Integration and end-to-end tests
|
tests/ # Integration and end-to-end tests
|
||||||
.config/ # Configuration files (linters, formatters, CI)
|
docs/ # Architecture, design, briefings, sprints, workshops
|
||||||
.cache/ # Local caches for testing/linting (gitignored)
|
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
|
||||||
docs/
|
.claude/ # Agents, skills, rules
|
||||||
discussions/ # Discussion rounds (all rounds archived here per D-022)
|
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
|
||||||
architecture/ # Technical architecture documents
|
|
||||||
design/ # Game design documents
|
|
||||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
|
||||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
|
||||||
db/
|
|
||||||
schema.sql # Database schema
|
|
||||||
connectors/ # Connector scripts for SQLite and Qdrant
|
|
||||||
config.json # Endpoint configuration
|
|
||||||
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
|
|
||||||
sqlite_connector.py # SQLite mini MCP
|
|
||||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
|
||||||
.claude/
|
|
||||||
agents/ # Agent personality files
|
|
||||||
skills/ # Skill definitions
|
|
||||||
decisions/ # Decision domain files (source of truth)
|
|
||||||
README.md # Domain index and query examples
|
|
||||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
|
||||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
|
||||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
|
||||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
|
||||||
process.md # D-004, D-021, D-022
|
|
||||||
questions.md # Q-001 through Q-011
|
|
||||||
rejected.md # R-001 through R-010
|
|
||||||
DECISIONS.md # Redirect to decisions/ directory
|
|
||||||
TEAM.md # Team roster and roles
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Full annotated tree: `.claude/rules/project-structure.md`
|
||||||
|
|
||||||
## DevOps
|
## DevOps
|
||||||
|
|
||||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||||
@@ -53,15 +29,12 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
|||||||
|
|
||||||
### Worktree boundaries
|
### Worktree boundaries
|
||||||
|
|
||||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||||
|
|
||||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||||
|
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||||
|
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
|
||||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
|
||||||
- Do not navigate above the git root unless explicitly instructed.
|
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
@@ -73,109 +46,23 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
|||||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||||
|
|
||||||
### Ticket and database access
|
### CLI tools
|
||||||
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
|
|
||||||
```bash
|
|
||||||
db/connectors/ticket list --sprint 2 --team server
|
|
||||||
db/connectors/ticket show 78
|
|
||||||
db/connectors/ticket sprint --active
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sprint CLI
|
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
|
||||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
|
||||||
```bash
|
|
||||||
db/connectors/sprint status # Current sprint progress
|
|
||||||
db/connectors/sprint status --team server # Team-scoped view
|
|
||||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
|
||||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
|
||||||
db/connectors/sprint start # Activate a planned sprint
|
|
||||||
db/connectors/sprint stop # Complete an active sprint
|
|
||||||
```
|
|
||||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
|
||||||
|
|
||||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
| Tool | Command | Full reference |
|
||||||
```bash
|
|------|---------|----------------|
|
||||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||||
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||||
```
|
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||||
|
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||||
### Qdrant / document search
|
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||||
```bash
|
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||||
db/connectors/qdrant-search "asymmetric information design"
|
|
||||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
|
||||||
db/connectors/qdrant-health
|
|
||||||
db/connectors/qdrant-count
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitea access (tea CLI)
|
|
||||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
|
||||||
|
|
||||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List open PRs
|
|
||||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
|
||||||
|
|
||||||
# View a PR with comments
|
|
||||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
|
||||||
|
|
||||||
# Post a comment on a PR (or issue)
|
|
||||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
|
|
||||||
|
|
||||||
# Approve a PR
|
|
||||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
|
||||||
|
|
||||||
# List issues
|
|
||||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
|
||||||
```
|
|
||||||
|
|
||||||
Key rules:
|
|
||||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
|
||||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
|
||||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
|
||||||
```bash
|
|
||||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
|
||||||
# Step 2: Post via cat
|
|
||||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
|
||||||
```
|
|
||||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
|
||||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
|
||||||
|
|
||||||
### File conventions
|
### File conventions
|
||||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||||
|
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||||
- Briefings: one per agent, updated after decision-producing rounds
|
- Briefings: one per agent, updated after decision-producing rounds
|
||||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||||
|
|
||||||
### Commit conventions
|
|
||||||
Use conventional commits with project-specific scopes:
|
|
||||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
|
||||||
|
|
||||||
### Pull requests
|
|
||||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
|
||||||
|
|
||||||
Always provide all required flags to ensure non-interactive execution:
|
|
||||||
```bash
|
|
||||||
tea pr create \
|
|
||||||
--repo jpmschweitzer/settled-reach \
|
|
||||||
--login schweitz \
|
|
||||||
--title "feat(scope): short description" \
|
|
||||||
--description "PR body here" \
|
|
||||||
--base main \
|
|
||||||
--head branch-name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Large content pushes (team pattern)
|
|
||||||
When producing many files (wiki pages, content batches, bulk docs):
|
|
||||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
|
||||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
|
||||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
|
||||||
|
|
||||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
|
||||||
|
|
||||||
### Local services
|
|
||||||
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
|
||||||
- Qdrant: `http://tower-of-joy:6333/`
|
|
||||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
|
||||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||||
|
|
||||||
.PHONY: help setup build client server game stop test lint ci ci-client ci-server clean \
|
.PHONY: help setup build check-protocol client server game stop test lint ci ci-client ci-server clean \
|
||||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||||
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
|
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
|
||||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||||
@@ -28,6 +28,7 @@ help:
|
|||||||
@echo " make ci Run full CI pipeline locally"
|
@echo " make ci Run full CI pipeline locally"
|
||||||
@echo " make ci-client Run client CI checks"
|
@echo " make ci-client Run client CI checks"
|
||||||
@echo " make ci-server Run server CI checks"
|
@echo " make ci-server Run server CI checks"
|
||||||
|
@echo " make check-protocol Verify server/client protocol versions match"
|
||||||
@echo " make clean Remove build artifacts and caches"
|
@echo " make clean Remove build artifacts and caches"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make db-backup Backup shared database to git (main only)"
|
@echo " make db-backup Backup shared database to git (main only)"
|
||||||
@@ -82,7 +83,16 @@ setup-hooks:
|
|||||||
|
|
||||||
# --- Build ---
|
# --- Build ---
|
||||||
|
|
||||||
build: build-server build-client
|
check-protocol:
|
||||||
|
@SERVER_V=$$(grep 'pub const PROTOCOL_VERSION' server/src/bridge/types.rs | sed 's/.*= *//;s/[^0-9]//g'); \
|
||||||
|
CLIENT_V=$$(grep 'const PROTOCOL_VERSION' client/scripts/protocol/protocol.gd | sed 's/.*= *//;s/[^0-9]//g'); \
|
||||||
|
if [ "$$SERVER_V" != "$$CLIENT_V" ]; then \
|
||||||
|
echo "ERROR: Protocol version mismatch — server=$$SERVER_V, client=$$CLIENT_V"; \
|
||||||
|
echo " Fix: update client/scripts/protocol/protocol.gd to match server/src/bridge/types.rs"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
build: check-protocol build-server build-client
|
||||||
|
|
||||||
build-server:
|
build-server:
|
||||||
cd server && cargo build
|
cd server && cargo build
|
||||||
@@ -94,7 +104,7 @@ build-client:
|
|||||||
# --- Run ---
|
# --- Run ---
|
||||||
|
|
||||||
server:
|
server:
|
||||||
cd server && cargo run
|
cd server && cargo run --bin settled-reach-server
|
||||||
|
|
||||||
client:
|
client:
|
||||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||||
@@ -103,7 +113,7 @@ client:
|
|||||||
game: stop build
|
game: stop build
|
||||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||||
@echo "Starting server..."
|
@echo "Starting server..."
|
||||||
@cd server && cargo run &
|
@cd server && cargo run --bin settled-reach-server &
|
||||||
@sleep 2
|
@sleep 2
|
||||||
@echo "Starting client..."
|
@echo "Starting client..."
|
||||||
@SR_LIVE=1 $(GODOT) --path client
|
@SR_LIVE=1 $(GODOT) --path client
|
||||||
@@ -290,7 +300,7 @@ perf-baseline:
|
|||||||
|
|
||||||
debug-schedule:
|
debug-schedule:
|
||||||
@echo "Dumping bevy_ecs schedule graph..."
|
@echo "Dumping bevy_ecs schedule graph..."
|
||||||
@cd server && cargo run -- --dump-schedule
|
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
|
||||||
|
|
||||||
content-ron:
|
content-ron:
|
||||||
cd tooling/content-converter && cargo build --release
|
cd tooling/content-converter && cargo build --release
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://dfy0ye3srawos"
|
||||||
|
path="res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/amb_bar_layer.ogg"
|
||||||
|
dest_files=["res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://cne005dbmwt6d"
|
||||||
|
path="res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/amb_corridor_layer.ogg"
|
||||||
|
dest_files=["res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://b6ycjdvxpphfa"
|
||||||
|
path="res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/amb_station_base.ogg"
|
||||||
|
dest_files=["res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://dw2pokvg5d7v2"
|
||||||
|
path="res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/amb_workplace_layer.ogg"
|
||||||
|
dest_files=["res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://da81bx5y6iw87"
|
||||||
|
path="res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/sfx_footstep_metal_run.ogg"
|
||||||
|
dest_files=["res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="oggvorbisstr"
|
||||||
|
type="AudioStreamOggVorbis"
|
||||||
|
uid="uid://d14dx3q0qd483"
|
||||||
|
path="res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/sfx_footstep_metal_walk.ogg"
|
||||||
|
dest_files=["res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
importer="oggvorbisstr"
|
importer="oggvorbisstr"
|
||||||
type="AudioStreamOggVorbis"
|
type="AudioStreamOggVorbis"
|
||||||
uid="uid://caeuztw9ah5h5l"
|
uid="uid://cow7symyvpmal"
|
||||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://qbg8rfnetlqn"
|
||||||
|
path="res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/fonts/Michroma-Regular.ttf"
|
||||||
|
dest_files=["res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
Rendering=null
|
||||||
|
antialiasing=1
|
||||||
|
generate_mipmaps=false
|
||||||
|
disable_embedded_bitmaps=true
|
||||||
|
multichannel_signed_distance_field=false
|
||||||
|
msdf_pixel_range=8
|
||||||
|
msdf_size=48
|
||||||
|
allow_system_fallback=true
|
||||||
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
|
hinting=1
|
||||||
|
subpixel_positioning=4
|
||||||
|
keep_rounding_remainders=true
|
||||||
|
oversampling=0.0
|
||||||
|
Fallbacks=null
|
||||||
|
fallbacks=[]
|
||||||
|
Compress=null
|
||||||
|
compress=true
|
||||||
|
preload=[]
|
||||||
|
language_support={}
|
||||||
|
script_support={}
|
||||||
|
opentype_features={}
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -43,5 +43,5 @@ passive_opacity: 0.9
|
|||||||
# ENTRY TIMING
|
# ENTRY TIMING
|
||||||
# All entry types share the same lifetime and fade duration.
|
# All entry types share the same lifetime and fade duration.
|
||||||
# ============================================================
|
# ============================================================
|
||||||
entry_lifetime_seconds: 15.0
|
entry_lifetime_seconds: 45.0
|
||||||
entry_fade_seconds: 3.0
|
entry_fade_seconds: 5.0
|
||||||
|
|||||||
@@ -125,6 +125,17 @@ knowledge_panel:
|
|||||||
confidence_medium: "Likely"
|
confidence_medium: "Likely"
|
||||||
confidence_low: "Unconfirmed"
|
confidence_low: "Unconfirmed"
|
||||||
confidence_rumor: "Hearsay"
|
confidence_rumor: "Hearsay"
|
||||||
|
# D-041 KnowledgeConfidence levels — displayed in journal panel
|
||||||
|
confidence_direct: "Confirmed"
|
||||||
|
confidence_knowsdetails: "Detailed"
|
||||||
|
confidence_knowsof: "Known"
|
||||||
|
confidence_suspects: "Unconfirmed"
|
||||||
|
# D-041 KnowledgeSource labels — displayed in journal panel
|
||||||
|
source_directobservation: "Observed"
|
||||||
|
source_toldby: "Told"
|
||||||
|
source_heard: "Overheard"
|
||||||
|
source_inferred: "Inferred"
|
||||||
|
source_background: "Prior"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# TUTORIAL TEXT (DIEGETIC)
|
# TUTORIAL TEXT (DIEGETIC)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#1a1a2e"/></svg>
|
||||||
|
After Width: | Height: | Size: 119 B |
@@ -0,0 +1,43 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://caxeq5xayr0iy"
|
||||||
|
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://icon.svg"
|
||||||
|
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
svg/scale=1.0
|
||||||
|
editor/scale_with_editor_scale=false
|
||||||
|
editor/convert_colors_with_editor_theme=false
|
||||||
@@ -125,6 +125,11 @@ debug_overlay={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
open_journal={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
teleport_hub={
|
teleport_hub={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||||
|
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||||
|
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||||
|
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||||
|
|
||||||
[node name="Game" type="Node2D"]
|
[node name="Game" type="Node2D"]
|
||||||
script = ExtResource("1_main")
|
script = ExtResource("1_main")
|
||||||
@@ -118,6 +121,9 @@ zoom = Vector2(2, 2)
|
|||||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||||
layer = 10
|
layer = 10
|
||||||
|
|
||||||
|
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||||
|
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||||
|
|
||||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||||
|
|
||||||
@@ -130,6 +136,15 @@ layer = 10
|
|||||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
||||||
|
|
||||||
|
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
|
||||||
|
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
|
||||||
|
|
||||||
|
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
|
||||||
|
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
|
||||||
|
|
||||||
|
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
|
||||||
|
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
|
||||||
|
|
||||||
; --- UI layer (CanvasLayer 20) ---
|
; --- UI layer (CanvasLayer 20) ---
|
||||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||||
@@ -137,8 +152,6 @@ layer = 20
|
|||||||
|
|
||||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||||
|
|
||||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
|
||||||
|
|
||||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||||
|
|
||||||
; D-053: Stance indicator — top-right, color-coded
|
; D-053: Stance indicator — top-right, color-coded
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const FILTER_CUTOFF_DEFAULT := 20500.0
|
|||||||
# --- D-073: Zone crossfade constants ---
|
# --- D-073: Zone crossfade constants ---
|
||||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||||
|
|
||||||
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
|
# Maps server zone_id strings to ambient asset keys (filenames in res://assets/audio/).
|
||||||
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
||||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||||
@@ -112,15 +112,15 @@ func _setup_buses() -> void:
|
|||||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||||
|
|
||||||
func _scan_registry() -> void:
|
func _scan_registry() -> void:
|
||||||
_scan_dir("res://audio/")
|
_scan_dir("res://assets/audio/")
|
||||||
print("AudioManager: %d assets registered" % _registry.size())
|
print("AudioManager: %d assets registered" % _registry.size())
|
||||||
|
|
||||||
|
|
||||||
func _scan_dir(path: String) -> void:
|
func _scan_dir(path: String) -> void:
|
||||||
var dir := DirAccess.open(path)
|
var dir := DirAccess.open(path)
|
||||||
if dir == null:
|
if dir == null:
|
||||||
if path == "res://audio/":
|
if path == "res://assets/audio/":
|
||||||
print("AudioManager: res://audio/ not found — all play methods no-op")
|
print("AudioManager: res://assets/audio/ not found — all play methods no-op")
|
||||||
return
|
return
|
||||||
dir.list_dir_begin()
|
dir.list_dir_begin()
|
||||||
var file_name := dir.get_next()
|
var file_name := dir.get_next()
|
||||||
@@ -197,7 +197,7 @@ func stop_all_loops() -> void:
|
|||||||
|
|
||||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||||
# Maps server-sent sound event_type strings to audio asset keys.
|
# Maps server-sent sound event_type strings to audio asset keys.
|
||||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
|
|||||||
# refined when the server assigns explicit player entity IDs).
|
# refined when the server assigns explicit player entity IDs).
|
||||||
var player_entity_id: int = 1
|
var player_entity_id: int = 1
|
||||||
|
|
||||||
|
# #241: Follow target — entity_id of the NPC the player is following, -1 when not following.
|
||||||
|
# Stub for server ticket #241 (Follow verb). Client reads this for camera/UI behavior.
|
||||||
|
var follow_target_id: int = -1
|
||||||
|
|
||||||
# v4 fields (#404/#405)
|
# v4 fields (#404/#405)
|
||||||
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
||||||
|
|
||||||
@@ -66,6 +70,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
|||||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||||
|
|
||||||
|
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
|
||||||
|
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
|
||||||
|
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
|
||||||
|
# Populated from snapshot "poi_list" field — only updated when field present.
|
||||||
|
var discovered_pois: Array = []
|
||||||
|
|
||||||
|
# v14 fields (#174, #242): Character-filtered examine result.
|
||||||
|
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||||
|
var current_examine_result: Variant = null
|
||||||
|
|
||||||
|
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
|
||||||
|
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||||
|
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||||
|
var player_knowledge: Variant = null
|
||||||
|
|
||||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||||
var medium_sound_events: Array = []
|
var medium_sound_events: Array = []
|
||||||
@@ -236,16 +255,35 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
medium_sound_events = []
|
medium_sound_events = []
|
||||||
close_sound_events = []
|
close_sound_events = []
|
||||||
|
|
||||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||||
# positional indexing — use the same tile iteration below instead.
|
# Only update if the field is present — absence means "no change since last tick".
|
||||||
current_zone_id = ""
|
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||||
var _px := int(player_position.x)
|
discovered_pois = snapshot.discovered_pois
|
||||||
var _py := int(player_position.y)
|
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||||
for _ztile in visible_tiles:
|
discovered_pois = snapshot.poi_list
|
||||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
|
||||||
current_zone_id = _ztile.get("zone_id", "")
|
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||||
break
|
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||||
|
current_examine_result = snapshot.examine_result
|
||||||
|
else:
|
||||||
|
current_examine_result = null
|
||||||
|
|
||||||
|
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||||
|
# Only update when field is present (null means no change, server sends when KG changes).
|
||||||
|
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||||
|
player_knowledge = snapshot.player_knowledge
|
||||||
|
|
||||||
|
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
|
||||||
|
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
|
||||||
|
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
|
||||||
|
var _tile_by_coord: Dictionary = {}
|
||||||
|
for vtile in visible_tiles:
|
||||||
|
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||||
|
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||||
|
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
||||||
|
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||||
|
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||||
|
|
||||||
# v2: visible_tiles with visibility sectors
|
# v2: visible_tiles with visibility sectors
|
||||||
# Derives visible_positions when not explicitly provided (real server mode)
|
# Derives visible_positions when not explicitly provided (real server mode)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ enum Action {
|
|||||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||||
|
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||||
SET_FACING, # D-054: facing octant update (no movement)
|
SET_FACING, # D-054: facing octant update (no movement)
|
||||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
action = Action.TOGGLE_STANCE_DOWN
|
action = Action.TOGGLE_STANCE_DOWN
|
||||||
elif event.is_action_pressed("bug_report"):
|
elif event.is_action_pressed("bug_report"):
|
||||||
action = Action.BUG_REPORT
|
action = Action.BUG_REPORT
|
||||||
|
elif event.is_action_pressed("open_journal"):
|
||||||
|
action = Action.OPEN_JOURNAL
|
||||||
elif event.is_action_pressed("teleport_hub"):
|
elif event.is_action_pressed("teleport_hub"):
|
||||||
if GameState.gauntlet_mode:
|
if GameState.gauntlet_mode:
|
||||||
action = Action.TELEPORT_HUB
|
action = Action.TELEPORT_HUB
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://rqfw0ycyb4c6
|
||||||
@@ -91,14 +91,24 @@ const FACING_INDICATOR_SIZE: float = 6.0
|
|||||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||||
|
|
||||||
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
||||||
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
|
# Raised from D-076 default (640px) to 1200px for readability.
|
||||||
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
||||||
# two columns of text comfortably, leaves world game visible alongside.
|
# two columns of text comfortably, leaves world game visible alongside.
|
||||||
const DIALOGUE_MAX_WIDTH: int = 640
|
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||||
|
|
||||||
|
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||||
|
static func format_game_time(time_of_day: int) -> String:
|
||||||
|
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||||
|
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||||
|
|
||||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||||
|
|
||||||
|
# #117: Camera smoothing speed — exponential interpolation via manual lerp in main.gd.
|
||||||
|
# Same pattern as EntityRenderer.LERP_SPEED. At 8.0: ~55% convergence after 0.1s.
|
||||||
|
# Slightly softer than entity movement (12.0) for a touch of cinematic camera lag.
|
||||||
|
const CAMERA_SMOOTHING_SPEED: float = 8.0
|
||||||
|
|
||||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ extends Node2D
|
|||||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||||
|
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||||
|
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
|
||||||
|
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
|
||||||
|
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
|
||||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||||
@@ -26,7 +30,7 @@ var _last_dialogue_tick: int = -1
|
|||||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||||
|
|
||||||
@@ -35,13 +39,10 @@ const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before Listenin
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
print("The Settled Reach — client initialized")
|
print("The Settled Reach — client initialized")
|
||||||
|
|
||||||
# Disable camera smoothing during init. Camera2D's position_smoothing
|
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
||||||
# lerps an internal smoothed_camera_pos toward global_position each frame.
|
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
|
||||||
# That smoothed position initializes at (0,0) — the Camera2D's default in
|
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
|
||||||
# the .tscn. Even after we set global_position to the player coords,
|
# would conflict because we'd be setting global_position to the target every frame.
|
||||||
# smoothing causes the viewport to still show (0,0) on the first rendered
|
|
||||||
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
|
|
||||||
# uses global_position directly. Re-enabled in _process() after anchor.
|
|
||||||
camera.position_smoothing_enabled = false
|
camera.position_smoothing_enabled = false
|
||||||
|
|
||||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||||
@@ -50,7 +51,7 @@ func _ready() -> void:
|
|||||||
# Camera anchor: snap to player position before the first frame renders.
|
# Camera anchor: snap to player position before the first frame renders.
|
||||||
# In test mode poll_snapshot() returns synchronously — position is set
|
# In test mode poll_snapshot() returns synchronously — position is set
|
||||||
# immediately. In live mode the snapshot isn't available yet — _process
|
# immediately. In live mode the snapshot isn't available yet — _process
|
||||||
# handles it. No reset_smoothing() needed: smoothing is OFF.
|
# handles it via the lerp block in _process().
|
||||||
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||||
if first_snapshot != null:
|
if first_snapshot != null:
|
||||||
GameState.apply_snapshot(first_snapshot)
|
GameState.apply_snapshot(first_snapshot)
|
||||||
@@ -70,7 +71,7 @@ func _ready() -> void:
|
|||||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
# Main game loop: poll snapshot, apply state, flush input
|
# Main game loop: poll snapshot, apply state, flush input
|
||||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||||
if snapshot != null:
|
if snapshot != null:
|
||||||
@@ -101,13 +102,15 @@ func _process(_delta: float) -> void:
|
|||||||
interaction_list.set_insert_active(insert_state)
|
interaction_list.set_insert_active(insert_state)
|
||||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||||
interaction_prompt.set_insert_active(insert_state)
|
interaction_prompt.set_insert_active(insert_state)
|
||||||
|
if minimap and minimap.has_method("set_insert_active"):
|
||||||
|
minimap.set_insert_active(insert_state)
|
||||||
|
|
||||||
# D-057: Update interaction list from game state
|
# D-057: Update interaction list from game state
|
||||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
if interaction_list.is_showing():
|
if interaction_list.is_showing():
|
||||||
interaction_list._hide()
|
interaction_list.hide_list()
|
||||||
else:
|
else:
|
||||||
interaction_list.update_from_state()
|
interaction_list.update_from_state()
|
||||||
|
|
||||||
@@ -134,6 +137,14 @@ func _process(_delta: float) -> void:
|
|||||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||||
checklist_overlay.update_from_state()
|
checklist_overlay.update_from_state()
|
||||||
|
|
||||||
|
# #263: Update time display (D-013, D-031)
|
||||||
|
if time_display and time_display.has_method("update_from_state"):
|
||||||
|
time_display.update_from_state()
|
||||||
|
|
||||||
|
# #264: Update journal panel — auto-close on dialogue, refresh if open
|
||||||
|
if journal_panel and journal_panel.has_method("update_from_state"):
|
||||||
|
journal_panel.update_from_state()
|
||||||
|
|
||||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||||
debug_overlay.update_from_state()
|
debug_overlay.update_from_state()
|
||||||
@@ -148,6 +159,9 @@ func _process(_delta: float) -> void:
|
|||||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||||
_update_listening_focus()
|
_update_listening_focus()
|
||||||
|
|
||||||
|
# #174: Show examine result if server sent one this tick (#242)
|
||||||
|
_consume_examine_result()
|
||||||
|
|
||||||
# Show monologue if server sent one this tick (#414)
|
# Show monologue if server sent one this tick (#414)
|
||||||
_consume_monologue()
|
_consume_monologue()
|
||||||
|
|
||||||
@@ -159,23 +173,18 @@ func _process(_delta: float) -> void:
|
|||||||
_consume_conversation_ended()
|
_consume_conversation_ended()
|
||||||
_consume_dialogue_response()
|
_consume_dialogue_response()
|
||||||
|
|
||||||
# Track camera to player position every frame (D-015: locked, no panning)
|
# Track camera to player (D-015: locked, fixed-north).
|
||||||
|
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||||
|
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
||||||
|
# Init: camera already snapped in _ready() or late-anchor path above.
|
||||||
if _camera_anchored:
|
if _camera_anchored:
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
var target := GameState.player_position * Constants.TILE_SIZE
|
||||||
|
|
||||||
# Re-enable smoothing after the first anchored frame. The frame that just
|
|
||||||
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
|
||||||
# turn smoothing back on and sync its internal state so subsequent frames
|
|
||||||
# get smooth camera tracking during gameplay.
|
|
||||||
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
|
||||||
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
|
||||||
# re-enable block in the same _process() call undoing the snap.
|
|
||||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
|
||||||
if _teleport_in_progress:
|
if _teleport_in_progress:
|
||||||
|
camera.global_position = target
|
||||||
_teleport_in_progress = false
|
_teleport_in_progress = false
|
||||||
else:
|
else:
|
||||||
camera.position_smoothing_enabled = true
|
var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta)
|
||||||
camera.reset_smoothing()
|
camera.global_position = camera.global_position.lerp(target, weight)
|
||||||
|
|
||||||
# Send queued input to simulation
|
# Send queued input to simulation
|
||||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||||
@@ -187,6 +196,10 @@ func _process(_delta: float) -> void:
|
|||||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||||
bug_report_dialog.start_capture()
|
bug_report_dialog.start_capture()
|
||||||
continue
|
continue
|
||||||
|
# #264: J — client-only, toggle knowledge journal panel
|
||||||
|
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||||
|
_toggle_journal()
|
||||||
|
continue
|
||||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||||
if input.action == InputMapper.Action.OPEN_MENU:
|
if input.action == InputMapper.Action.OPEN_MENU:
|
||||||
if settings_dialog:
|
if settings_dialog:
|
||||||
@@ -250,22 +263,16 @@ func _play_close_sound_events() -> void:
|
|||||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||||
# enters the cognitive delay recognition queue for the first time.
|
# enters the cognitive delay recognition queue for the first time.
|
||||||
# "The chime marks the character's attention shifting" (D-067).
|
# "The chime marks the character's attention shifting" (D-067).
|
||||||
# Entities that complete recognition (leave pending_recognitions) are removed
|
# IDs persist for the session — one chime per entity, no re-trigger on
|
||||||
# from _known_recognition_ids so they can chime again if re-encountered.
|
# fog oscillation or server re-send. Cleared on room change (teleport).
|
||||||
func _play_recognition_chimes() -> void:
|
func _play_recognition_chimes() -> void:
|
||||||
var active_ids: Dictionary = {}
|
|
||||||
for rec in GameState.pending_recognitions:
|
for rec in GameState.pending_recognitions:
|
||||||
if not rec is Dictionary or not rec.has("entity_id"):
|
if not rec is Dictionary or not rec.has("entity_id"):
|
||||||
continue
|
continue
|
||||||
var eid: int = rec.entity_id
|
var eid: int = rec.entity_id
|
||||||
active_ids[eid] = true
|
|
||||||
if not _known_recognition_ids.has(eid):
|
if not _known_recognition_ids.has(eid):
|
||||||
_known_recognition_ids[eid] = true
|
_known_recognition_ids[eid] = true
|
||||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||||
# Expire IDs no longer in the recognition queue
|
|
||||||
for eid in _known_recognition_ids.keys():
|
|
||||||
if not active_ids.has(eid):
|
|
||||||
_known_recognition_ids.erase(eid)
|
|
||||||
|
|
||||||
|
|
||||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||||
@@ -327,6 +334,9 @@ func _consume_dialogue() -> void:
|
|||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
return
|
return
|
||||||
_last_dialogue_tick = GameState.current_tick
|
_last_dialogue_tick = GameState.current_tick
|
||||||
|
# #264: Close journal when dialogue opens (cannot be open simultaneously)
|
||||||
|
if journal_panel and journal_panel.has_method("close"):
|
||||||
|
journal_panel.close()
|
||||||
var dlg: Dictionary = GameState.current_dialogue
|
var dlg: Dictionary = GameState.current_dialogue
|
||||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||||
@@ -358,11 +368,16 @@ func _consume_conversation_ended() -> void:
|
|||||||
|
|
||||||
|
|
||||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||||
|
# Updates dialogue_box entity display registry with speaker identity from the wire.
|
||||||
func _consume_dialogue_response() -> void:
|
func _consume_dialogue_response() -> void:
|
||||||
if GameState.dialogue_response == null or not dialogue_box:
|
if GameState.dialogue_response == null or not dialogue_box:
|
||||||
return
|
return
|
||||||
var dr: Dictionary = GameState.dialogue_response
|
var dr: Dictionary = GameState.dialogue_response
|
||||||
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
|
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||||
|
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||||
|
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||||
|
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||||
|
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
|
||||||
GameState.dialogue_response = null
|
GameState.dialogue_response = null
|
||||||
|
|
||||||
|
|
||||||
@@ -434,11 +449,8 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
|||||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||||
func _teleport_transition() -> void:
|
func _teleport_transition() -> void:
|
||||||
# Snap camera: disable smoothing, force re-anchor.
|
# Set teleport flag — the camera tracking block in _process() will snap
|
||||||
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
# to the player's new position this frame (no lerp). Flag clears after snap.
|
||||||
# re-enable block at the bottom of _process() doesn't undo the snap.
|
|
||||||
camera.position_smoothing_enabled = false
|
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
|
||||||
_camera_anchored = true
|
_camera_anchored = true
|
||||||
_teleport_in_progress = true
|
_teleport_in_progress = true
|
||||||
|
|
||||||
@@ -446,6 +458,7 @@ func _teleport_transition() -> void:
|
|||||||
GameState.current_monologue = null
|
GameState.current_monologue = null
|
||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
GameState.dialogue_active = false
|
GameState.dialogue_active = false
|
||||||
|
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
dialogue_box.hide_dialogue()
|
dialogue_box.hide_dialogue()
|
||||||
|
|
||||||
@@ -462,6 +475,33 @@ func _teleport_transition() -> void:
|
|||||||
tween.tween_callback(_flash_rect.queue_free)
|
tween.tween_callback(_flash_rect.queue_free)
|
||||||
|
|
||||||
|
|
||||||
|
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||||
|
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
||||||
|
func _consume_examine_result() -> void:
|
||||||
|
if GameState.current_examine_result == null or not examine_display:
|
||||||
|
return
|
||||||
|
var result: Dictionary = GameState.current_examine_result
|
||||||
|
# Dismiss existing examine result if dialogue is active (focus priority)
|
||||||
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
|
if examine_display.has_method("dismiss"):
|
||||||
|
examine_display.dismiss()
|
||||||
|
else:
|
||||||
|
if examine_display.has_method("show_result"):
|
||||||
|
examine_display.show_result(result)
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
|
||||||
|
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
||||||
|
func _toggle_journal() -> void:
|
||||||
|
if not journal_panel:
|
||||||
|
return
|
||||||
|
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
||||||
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
|
return
|
||||||
|
if journal_panel.has_method("toggle"):
|
||||||
|
journal_panel.toggle()
|
||||||
|
|
||||||
|
|
||||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||||
func _screen_flash(color: Color, duration: float) -> void:
|
func _screen_flash(color: Color, duration: float) -> void:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class_name Protocol
|
|||||||
|
|
||||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||||
## Reject snapshots where version != this value.
|
## Reject snapshots where version != this value.
|
||||||
const PROTOCOL_VERSION: int = 9
|
const PROTOCOL_VERSION: int = 14
|
||||||
|
|
||||||
|
|
||||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||||
@@ -206,6 +206,70 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"target_id": int(raw_end.get("target_id", 0)),
|
"target_id": int(raw_end.get("target_id", 0)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||||
|
# Each entry: {poi_id, name, x, y, z, category}. Positions in sim tile coords.
|
||||||
|
var poi_list: Array = []
|
||||||
|
var raw_pois: Variant = raw.get("poi_list")
|
||||||
|
if raw_pois is Array:
|
||||||
|
for raw_poi in raw_pois:
|
||||||
|
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||||
|
poi_list.append({
|
||||||
|
"poi_id": str(raw_poi["poi_id"]),
|
||||||
|
"name": str(raw_poi.get("name", "")),
|
||||||
|
"x": int(raw_poi["x"]),
|
||||||
|
"y": int(raw_poi["y"]),
|
||||||
|
"z": int(raw_poi.get("z", 0)),
|
||||||
|
"category": str(raw_poi.get("category", "Location")),
|
||||||
|
})
|
||||||
|
|
||||||
|
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||||
|
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||||
|
var examine_result: Variant = null
|
||||||
|
var raw_examine: Variant = raw.get("examine_result")
|
||||||
|
if raw_examine is Dictionary and raw_examine.has("text"):
|
||||||
|
examine_result = {
|
||||||
|
"entity_id": int(raw_examine.get("entity_id", 0)),
|
||||||
|
"text": str(raw_examine["text"]),
|
||||||
|
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||||
|
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||||
|
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||||
|
var player_knowledge: Variant = null
|
||||||
|
var raw_pk: Variant = raw.get("player_knowledge")
|
||||||
|
if raw_pk is Dictionary:
|
||||||
|
var kg_entities: Array = []
|
||||||
|
var raw_kg_entities: Variant = raw_pk.get("entities")
|
||||||
|
if raw_kg_entities is Array:
|
||||||
|
for raw_ke in raw_kg_entities:
|
||||||
|
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||||
|
kg_entities.append({
|
||||||
|
"entity_id": int(raw_ke["entity_id"]),
|
||||||
|
"name": str(raw_ke.get("name", "Unknown")),
|
||||||
|
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||||
|
"source": str(raw_ke.get("source", "")),
|
||||||
|
"state": str(raw_ke.get("state", "Active")),
|
||||||
|
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||||
|
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||||
|
})
|
||||||
|
var kg_facts: Array = []
|
||||||
|
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||||
|
if raw_kg_facts is Array:
|
||||||
|
for raw_kf in raw_kg_facts:
|
||||||
|
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||||
|
kg_facts.append({
|
||||||
|
"fact_id": str(raw_kf["fact_id"]),
|
||||||
|
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||||
|
"source": str(raw_kf.get("source", "")),
|
||||||
|
"state": str(raw_kf.get("state", "Active")),
|
||||||
|
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||||
|
})
|
||||||
|
player_knowledge = {
|
||||||
|
"entities": kg_entities,
|
||||||
|
"facts": kg_facts,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tick": tick,
|
"tick": tick,
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
@@ -223,6 +287,9 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"pending_recognitions": pending_recognitions,
|
"pending_recognitions": pending_recognitions,
|
||||||
"conversation_events": conversation_events,
|
"conversation_events": conversation_events,
|
||||||
"conversation_ended": conversation_ended,
|
"conversation_ended": conversation_ended,
|
||||||
|
"poi_list": poi_list,
|
||||||
|
"examine_result": examine_result,
|
||||||
|
"player_knowledge": player_knowledge,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class_name EntityRenderer
|
|||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
# Entity renderer — manages entity sprites under the Entities node
|
# Entity renderer — manages entity sprites under the Entities node
|
||||||
# Creates/updates/removes ColorRect children based on entity data
|
# Creates/updates/removes Sprite2D children based on entity data
|
||||||
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
||||||
#
|
#
|
||||||
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
||||||
@@ -11,10 +11,15 @@ extends Node2D
|
|||||||
#
|
#
|
||||||
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
||||||
# color from RelationshipState via the knowledge graph.
|
# color from RelationshipState via the knowledge graph.
|
||||||
|
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
|
||||||
|
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
|
||||||
|
|
||||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||||
const ENTITY_SIZE: int = 24
|
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
|
||||||
const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile
|
const ENTITY_WIDTH: int = 24
|
||||||
|
const ENTITY_HEIGHT: int = 32
|
||||||
|
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
|
||||||
|
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
|
||||||
|
|
||||||
# Lerp speed — framerate-independent exponential smoothing.
|
# Lerp speed — framerate-independent exponential smoothing.
|
||||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||||
@@ -24,7 +29,8 @@ const LERP_SPEED: float = 12.0
|
|||||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
|
||||||
|
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
|
||||||
|
|
||||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||||
const COLOR_FADE_DURATION: float = 0.5
|
const COLOR_FADE_DURATION: float = 0.5
|
||||||
@@ -45,7 +51,7 @@ func _process(delta: float) -> void:
|
|||||||
if not node.position.is_equal_approx(target):
|
if not node.position.is_equal_approx(target):
|
||||||
node.position = node.position.lerp(target, weight)
|
node.position = node.position.lerp(target, weight)
|
||||||
|
|
||||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
|
||||||
var finished_ids: Array = []
|
var finished_ids: Array = []
|
||||||
for entity_id in _entity_tweens.keys():
|
for entity_id in _entity_tweens.keys():
|
||||||
if not entity_nodes.has(entity_id):
|
if not entity_nodes.has(entity_id):
|
||||||
@@ -54,8 +60,9 @@ func _process(delta: float) -> void:
|
|||||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||||
tween_data.elapsed += delta
|
tween_data.elapsed += delta
|
||||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
|
||||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
if node_s:
|
||||||
|
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
|
||||||
if t >= 1.0:
|
if t >= 1.0:
|
||||||
finished_ids.append(entity_id)
|
finished_ids.append(entity_id)
|
||||||
for eid in finished_ids:
|
for eid in finished_ids:
|
||||||
@@ -89,15 +96,26 @@ func update_entities(entities: Array) -> void:
|
|||||||
for entity_id in ids_to_remove:
|
for entity_id in ids_to_remove:
|
||||||
_remove_entity_node(entity_id)
|
_remove_entity_node(entity_id)
|
||||||
|
|
||||||
# Create a new entity node with D-033 color and optional facing indicator
|
|
||||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
|
||||||
var entity_node = ColorRect.new()
|
|
||||||
entity_node.name = "Entity_" + str(entity_id)
|
|
||||||
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
|
|
||||||
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
|
||||||
|
|
||||||
# D-033 color by relationship (#521)
|
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
|
||||||
entity_node.color = _color_for_kind(entity_data)
|
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||||
|
var entity_node := Sprite2D.new()
|
||||||
|
entity_node.name = "Entity_" + str(entity_id)
|
||||||
|
# centered=false: top-left origin aligns with tile grid.
|
||||||
|
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
|
||||||
|
entity_node.centered = false
|
||||||
|
entity_node.scale = Vector2(0.5, 0.5)
|
||||||
|
|
||||||
|
# Load sprite for current facing direction
|
||||||
|
var direction := _entity_direction(entity_id, entity_data)
|
||||||
|
_entity_facing[entity_id] = direction
|
||||||
|
var tex := _load_sprite_texture(direction)
|
||||||
|
if tex == null:
|
||||||
|
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
|
||||||
|
entity_node.texture = tex
|
||||||
|
|
||||||
|
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
|
||||||
|
entity_node.self_modulate = _color_for_kind(entity_data)
|
||||||
|
|
||||||
add_child(entity_node)
|
add_child(entity_node)
|
||||||
entity_nodes[entity_id] = entity_node
|
entity_nodes[entity_id] = entity_node
|
||||||
@@ -110,61 +128,66 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
|||||||
# Snap to initial position (no lerp on first appearance)
|
# Snap to initial position (no lerp on first appearance)
|
||||||
if entity_data.has("x") and entity_data.has("y"):
|
if entity_data.has("x") and entity_data.has("y"):
|
||||||
var target := Vector2(
|
var target := Vector2(
|
||||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
|
||||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
entity_node.position = target
|
entity_node.position = target
|
||||||
_entity_targets[entity_id] = target
|
_entity_targets[entity_id] = target
|
||||||
|
|
||||||
_update_entity_node(entity_id, entity_data)
|
_update_entity_node(entity_id, entity_data)
|
||||||
|
|
||||||
# Update an existing entity node (target position, visibility dimming, facing)
|
|
||||||
|
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
|
||||||
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||||
if not entity_nodes.has(entity_id):
|
if not entity_nodes.has(entity_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
var entity_node = entity_nodes[entity_id]
|
var node = entity_nodes[entity_id]
|
||||||
|
|
||||||
# Update target position — the lerp in _process() will smoothly move there.
|
# Update target position — the lerp in _process() will smoothly move there.
|
||||||
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
||||||
if entity_data.has("x") and entity_data.has("y"):
|
if entity_data.has("x") and entity_data.has("y"):
|
||||||
_entity_targets[entity_id] = Vector2(
|
_entity_targets[entity_id] = Vector2(
|
||||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
|
||||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
|
|
||||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
# #540: Update sprite texture when facing direction changes
|
||||||
|
var new_dir := _entity_direction(entity_id, entity_data)
|
||||||
|
if new_dir != _entity_facing.get(entity_id, ""):
|
||||||
|
_entity_facing[entity_id] = new_dir
|
||||||
|
var new_tex := _load_sprite_texture(new_dir)
|
||||||
|
if new_tex != null:
|
||||||
|
(node as Sprite2D).texture = new_tex
|
||||||
|
# null: keep previous texture rather than going invisible mid-game
|
||||||
|
|
||||||
|
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
|
||||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
|
||||||
if new_rel != old_rel:
|
|
||||||
_entity_relationships[entity_id] = new_rel
|
_entity_relationships[entity_id] = new_rel
|
||||||
var new_color := _color_for_kind(entity_data)
|
|
||||||
_entity_tweens[entity_id] = {
|
_entity_tweens[entity_id] = {
|
||||||
"from": entity_node.color,
|
"from": (node as Sprite2D).self_modulate,
|
||||||
"target": new_color,
|
"target": _color_for_kind(entity_data),
|
||||||
"elapsed": 0.0,
|
"elapsed": 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
# D-015: Peripheral vision dimming via modulate.a.
|
||||||
# are compositionally independent — both can change simultaneously without
|
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
|
||||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
|
||||||
|
|
||||||
# v2: Peripheral vision dimming (D-015)
|
|
||||||
# null visibility (v1 backward compat) defaults to full alpha
|
|
||||||
var visibility: Variant = entity_data.get("visibility")
|
var visibility: Variant = entity_data.get("visibility")
|
||||||
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
|
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
|
||||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
if not is_equal_approx(node.modulate.a, target_alpha):
|
||||||
entity_node.modulate.a = target_alpha
|
node.modulate.a = target_alpha
|
||||||
|
|
||||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||||
if entity_id == GameState.player_entity_id:
|
if entity_id == GameState.player_entity_id:
|
||||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
var indicator = node.get_node_or_null("FacingIndicator")
|
||||||
if indicator != null:
|
if indicator != null:
|
||||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||||
# Rotate from North basis: add PI/2 to convert.
|
# Rotate from North basis: add PI/2 to convert.
|
||||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||||
|
|
||||||
|
|
||||||
# Remove an entity node
|
# Remove an entity node
|
||||||
func _remove_entity_node(entity_id: int) -> void:
|
func _remove_entity_node(entity_id: int) -> void:
|
||||||
if not entity_nodes.has(entity_id):
|
if not entity_nodes.has(entity_id):
|
||||||
@@ -176,13 +199,49 @@ func _remove_entity_node(entity_id: int) -> void:
|
|||||||
_entity_targets.erase(entity_id)
|
_entity_targets.erase(entity_id)
|
||||||
_entity_relationships.erase(entity_id)
|
_entity_relationships.erase(entity_id)
|
||||||
_entity_tweens.erase(entity_id)
|
_entity_tweens.erase(entity_id)
|
||||||
|
_entity_facing.erase(entity_id)
|
||||||
|
|
||||||
|
|
||||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||||
return Constants.color_for_entity_kind(entity_data)
|
return Constants.color_for_entity_kind(entity_data)
|
||||||
|
|
||||||
# Add a facing direction indicator triangle to the player entity
|
|
||||||
func _add_facing_indicator(parent_node: Control) -> void:
|
# #540: Map entity to current 4-direction sprite key.
|
||||||
|
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
|
||||||
|
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
|
||||||
|
if entity_id == GameState.player_entity_id:
|
||||||
|
return _octant_to_direction(GameState.player_facing)
|
||||||
|
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
|
||||||
|
return "south"
|
||||||
|
|
||||||
|
|
||||||
|
# Map 8-direction octant string to nearest 4-direction sprite key.
|
||||||
|
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
|
||||||
|
static func _octant_to_direction(octant: String) -> String:
|
||||||
|
match octant:
|
||||||
|
"North", "Northwest": return "north"
|
||||||
|
"Northeast", "East": return "east"
|
||||||
|
"Southeast", "South": return "south"
|
||||||
|
"Southwest", "West": return "west"
|
||||||
|
_:
|
||||||
|
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
|
||||||
|
return "south"
|
||||||
|
|
||||||
|
|
||||||
|
# Load the sprite texture for the given 4-direction key.
|
||||||
|
# Falls back to null with a push_warning if the asset is missing.
|
||||||
|
static func _load_sprite_texture(direction: String) -> Texture2D:
|
||||||
|
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||||
|
if ResourceLoader.exists(path):
|
||||||
|
return load(path) as Texture2D
|
||||||
|
push_warning("EntityRenderer: sprite not found: %s" % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
# Add a facing direction indicator triangle to the player entity.
|
||||||
|
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
|
||||||
|
func _add_facing_indicator(parent_node: Node2D) -> void:
|
||||||
var indicator := Polygon2D.new()
|
var indicator := Polygon2D.new()
|
||||||
indicator.name = "FacingIndicator"
|
indicator.name = "FacingIndicator"
|
||||||
var s := Constants.FACING_INDICATOR_SIZE
|
var s := Constants.FACING_INDICATOR_SIZE
|
||||||
@@ -194,6 +253,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
|||||||
Vector2(s * 0.6, -offset + s * 0.4),
|
Vector2(s * 0.6, -offset + s * 0.4),
|
||||||
])
|
])
|
||||||
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
||||||
# Position at center of parent ColorRect — rotation around this point
|
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
|
||||||
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
# Indicator rotates around this point to track player facing direction.
|
||||||
|
indicator.position = Vector2(32.0, 32.0)
|
||||||
parent_node.add_child(indicator)
|
parent_node.add_child(indicator)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cb10ir0idsr0g
|
||||||
@@ -12,6 +12,7 @@ extends TileMapLayer
|
|||||||
# (4,0) = reset_plate — amber (#502)
|
# (4,0) = reset_plate — amber (#502)
|
||||||
|
|
||||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||||
|
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
|
||||||
|
|
||||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||||
|
|
||||||
@@ -64,6 +65,10 @@ func _setup_tileset() -> void:
|
|||||||
|
|
||||||
# Update tiles from snapshot data
|
# Update tiles from snapshot data
|
||||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
# tiles: Array of {x: int, y: int, z: int, type: String}
|
||||||
|
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
||||||
|
# NOT the Godot scene z_index (which controls render order within a floor).
|
||||||
|
# This node only renders floor-level 0. Higher floor levels will be handled
|
||||||
|
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
|
||||||
func update_tiles(tiles: Array) -> void:
|
func update_tiles(tiles: Array) -> void:
|
||||||
if not _initialized:
|
if not _initialized:
|
||||||
return
|
return
|
||||||
@@ -74,6 +79,12 @@ func update_tiles(tiles: Array) -> void:
|
|||||||
if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"):
|
if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Floor-level filter: only render tiles at ground floor.
|
||||||
|
# Upper floor tiles (level 1+) are for future multi-floor nodes.
|
||||||
|
var tile_z: int = tile_data.get("z", 0)
|
||||||
|
if tile_z != GROUND_FLOOR:
|
||||||
|
continue
|
||||||
|
|
||||||
var tile_type_str: String = tile_data.type
|
var tile_type_str: String = tile_data.type
|
||||||
if not TILE_TYPE_MAP.has(tile_type_str):
|
if not TILE_TYPE_MAP.has(tile_type_str):
|
||||||
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func _draw() -> void:
|
|||||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||||
var line_count := max(left_lines.size(), right_lines.size())
|
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||||
|
|
||||||
# Background
|
# Background
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c55ow14m345vv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://duvqpvheku2j2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d30by4d62g7ts
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b1tata3wjyb46
|
||||||
@@ -97,6 +97,20 @@ func test_camera_smoothing_off_after_ready() -> void:
|
|||||||
assert_that(camera.position_smoothing_enabled).is_false()
|
assert_that(camera.position_smoothing_enabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||||
|
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
|
||||||
|
# CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.position_smoothing_enabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
# --- Camera behavior across frames ---
|
# --- Camera behavior across frames ---
|
||||||
|
|
||||||
func test_camera_tracks_player_after_process() -> void:
|
func test_camera_tracks_player_after_process() -> void:
|
||||||
@@ -113,33 +127,22 @@ func test_camera_tracks_player_after_process() -> void:
|
|||||||
assert_that(camera.global_position).is_equal(expected)
|
assert_that(camera.global_position).is_equal(expected)
|
||||||
|
|
||||||
|
|
||||||
func test_camera_smoothing_reenabled_after_process() -> void:
|
func test_camera_lerps_toward_player_movement() -> void:
|
||||||
# After the first anchored frame, smoothing should be back on for gameplay.
|
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
|
||||||
var scene := load("res://scenes/main.tscn")
|
# After one 16ms frame the camera should be partway between old and new position.
|
||||||
_instance = scene.instantiate()
|
|
||||||
auto_free(_instance)
|
|
||||||
add_child(_instance)
|
|
||||||
|
|
||||||
_instance._process(0.016)
|
|
||||||
|
|
||||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
|
||||||
assert_that(camera.position_smoothing_enabled).is_true()
|
|
||||||
|
|
||||||
|
|
||||||
func test_camera_follows_player_movement() -> void:
|
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := load("res://scenes/main.tscn")
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
|
|
||||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
var initial_pos := camera.global_position
|
var initial_pos := camera.global_position # anchored at (320, 320)
|
||||||
|
|
||||||
# Move player north via SimBridge test mode
|
# Move player north via SimBridge test mode
|
||||||
SimBridge._test_input_queue.append("MoveNorth")
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
_instance._process(0.016)
|
_instance._process(0.016)
|
||||||
|
|
||||||
# Camera should have moved with the player
|
var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288)
|
||||||
assert_that(camera.global_position.y < initial_pos.y).is_true()
|
# Camera should have moved north (lower y) but NOT reached the target yet
|
||||||
assert_that(camera.global_position).is_equal(
|
assert_that(camera.global_position.y).is_less(initial_pos.y)
|
||||||
GameState.player_position * Constants.TILE_SIZE)
|
assert_that(camera.global_position.y).is_greater(new_target.y)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dhgcekc2ywr26
|
||||||
@@ -152,8 +152,8 @@ func test_entity_snap_on_first_appear() -> void:
|
|||||||
renderer.update_entities(entity)
|
renderer.update_entities(entity)
|
||||||
var node = renderer.entity_nodes[10]
|
var node = renderer.entity_nodes[10]
|
||||||
var expected := Vector2(
|
var expected := Vector2(
|
||||||
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
assert_that(node.position).override_failure_message(
|
assert_that(node.position).override_failure_message(
|
||||||
"Entity should snap to position on first appear (no lerp)"
|
"Entity should snap to position on first appear (no lerp)"
|
||||||
@@ -179,8 +179,8 @@ func test_entity_lerp_moves_toward_target() -> void:
|
|||||||
renderer._process(0.016)
|
renderer._process(0.016)
|
||||||
var after_pos: Vector2 = node.position
|
var after_pos: Vector2 = node.position
|
||||||
var target := Vector2(
|
var target := Vector2(
|
||||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
# Position should have moved toward target (x increased)
|
# Position should have moved toward target (x increased)
|
||||||
assert_that(after_pos.x > start_pos.x).override_failure_message(
|
assert_that(after_pos.x > start_pos.x).override_failure_message(
|
||||||
@@ -206,8 +206,8 @@ func test_entity_lerp_converges_within_300ms() -> void:
|
|||||||
"kind": {"variant": "Npc", "data": null}}]
|
"kind": {"variant": "Npc", "data": null}}]
|
||||||
renderer.update_entities(entity_moved)
|
renderer.update_entities(entity_moved)
|
||||||
var target := Vector2(
|
var target := Vector2(
|
||||||
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
||||||
for i in 20:
|
for i in 20:
|
||||||
@@ -298,8 +298,8 @@ func test_lerp_weight_increases_with_delta() -> void:
|
|||||||
var small_progress: float = small_node.position.x - small_start
|
var small_progress: float = small_node.position.x - small_start
|
||||||
# Reset position for large delta test
|
# Reset position for large delta test
|
||||||
small_node.position = Vector2(
|
small_node.position = Vector2(
|
||||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
# Large delta step
|
# Large delta step
|
||||||
var large_start: float = small_node.position.x
|
var large_start: float = small_node.position.x
|
||||||
|
|||||||
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_npc_uses_relationship_color_friendly() -> void:
|
func test_npc_uses_relationship_color_friendly() -> void:
|
||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_npc_uses_relationship_color_poi() -> void:
|
func test_npc_uses_relationship_color_poi() -> void:
|
||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_npc_uses_relationship_color_hostile() -> void:
|
func test_npc_uses_relationship_color_hostile() -> void:
|
||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_player_color_ignores_relationship() -> void:
|
func test_player_color_ignores_relationship() -> void:
|
||||||
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_object_color_ignores_relationship() -> void:
|
func test_object_color_ignores_relationship() -> void:
|
||||||
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||||
renderer.update_entities(entities_before)
|
renderer.update_entities(entities_before)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
# Change relationship to Hostile
|
# Change relationship to Hostile
|
||||||
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||||
renderer.update_entities(entities_after)
|
renderer.update_entities(entities_after)
|
||||||
# Immediately after update, color should NOT yet be the target
|
# Immediately after update, self_modulate should NOT yet be the target
|
||||||
var color_after_immediate: Color = node.color
|
var color_after_immediate: Color = node.self_modulate
|
||||||
if not _renderer_has_tween_support(renderer):
|
if not _renderer_has_tween_support(renderer):
|
||||||
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
# The color should NOT be exactly the target yet (tween in progress)
|
# The self_modulate should NOT be exactly the target yet (tween in progress)
|
||||||
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
|
|||||||
while elapsed < 1.5:
|
while elapsed < 1.5:
|
||||||
renderer._process(1.0 / 60.0)
|
renderer._process(1.0 / 60.0)
|
||||||
elapsed += 1.0 / 60.0
|
elapsed += 1.0 / 60.0
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_color_shift_mid_transition_retrigger() -> void:
|
func test_color_shift_mid_transition_retrigger() -> void:
|
||||||
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
|
|||||||
while elapsed < 1.0:
|
while elapsed < 1.0:
|
||||||
renderer._process(1.0 / 60.0)
|
renderer._process(1.0 / 60.0)
|
||||||
elapsed += 1.0 / 60.0
|
elapsed += 1.0 / 60.0
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
|
|||||||
return
|
return
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
var color_first: Color = node.color
|
var color_first: Color = node.self_modulate
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var color_second: Color = node.color
|
var color_second: Color = node.self_modulate
|
||||||
assert_that(color_first).is_equal(color_second)
|
assert_that(color_first).is_equal(color_second)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
|
|||||||
renderer.update_entities([friendly, hostile])
|
renderer.update_entities([friendly, hostile])
|
||||||
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
||||||
return false
|
return false
|
||||||
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
|
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
|
||||||
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
|
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
|
||||||
var f_color: Color = f_node.color
|
if not f_node or not h_node:
|
||||||
var h_color: Color = h_node.color
|
return false
|
||||||
|
var f_color: Color = f_node.self_modulate
|
||||||
|
var h_color: Color = h_node.self_modulate
|
||||||
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
||||||
renderer.update_entities([])
|
renderer.update_entities([])
|
||||||
return uses_rel
|
return uses_rel
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://qv808kduvlbk
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
## Sprint 18 — Dialogue UI hardening + examine result display (#174)
|
||||||
|
## Spec refs: D-061 (dialogue box), D-062 (invisible locked options), D-063 (confrontation),
|
||||||
|
## D-064 (walk-away), D-078 (overheard log)
|
||||||
|
##
|
||||||
|
## Test plan from joint.md:
|
||||||
|
## "Manual: examine result appears as overlay, auto-dismisses.
|
||||||
|
## Dialogue options confirmed: no locked/grayed options visible.
|
||||||
|
## Confrontation option in italic voice."
|
||||||
|
##
|
||||||
|
## Unit-testable coverage here:
|
||||||
|
## - D-062: no locked/grayed option mechanism in dialogue_box.gd
|
||||||
|
## - D-063: confrontation beat duration, signal, dim alpha
|
||||||
|
## - D-064: walk-away fires dialogue_dismissed signal
|
||||||
|
## - GameState: current_dialogue parsing
|
||||||
|
## - GameState: current_examine_result parsing (test-first — impl TBD in #174)
|
||||||
|
## - BBCode: escape contract (Hoshe #2 regression guard)
|
||||||
|
## - Dirty flag: _log_dirty optimization (Hoshe #1 regression guard)
|
||||||
|
class_name TestDialogueSprint18
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _make_dialogue_box() -> Control:
|
||||||
|
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||||
|
push_warning("TestDialogueSprint18: dialogue_box.tscn not found — scene tests skipped")
|
||||||
|
return null
|
||||||
|
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func _make_options(texts: Array[String], confrontation_flags: Array[bool] = []) -> Array:
|
||||||
|
var opts: Array = []
|
||||||
|
for i in range(texts.size()):
|
||||||
|
var opt: Dictionary = {
|
||||||
|
"text": texts[i],
|
||||||
|
"response_id": "r%d" % i,
|
||||||
|
"priority": i,
|
||||||
|
}
|
||||||
|
if confrontation_flags.size() > i:
|
||||||
|
opt["confrontation"] = confrontation_flags[i]
|
||||||
|
opts.append(opt)
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
if GameState.has("current_examine_result"):
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
if GameState.has("current_examine_result"):
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-062: No locked/grayed options
|
||||||
|
## Per D-062: locked options are INVISIBLE — not shown at all.
|
||||||
|
## The client renders all received options as active, clickable labels.
|
||||||
|
## Server responsibility: omit locked options from the array.
|
||||||
|
## Test verifies: no disabled/locked styling is applied to any rendered option.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_d062_rendered_options_have_no_disabled_state() -> void:
|
||||||
|
## D-062: All options from server render as active controls.
|
||||||
|
## No option should have mouse_filter=IGNORE (which would indicate disabled).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
box.show_dialogue("NPC", "Hello.", _make_options(["Option A", "Option B", "Option C"]))
|
||||||
|
|
||||||
|
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||||
|
assert_that(options_container != null).override_failure_message("OptionsContainer must exist").is_true()
|
||||||
|
|
||||||
|
var labels := options_container.get_children()
|
||||||
|
assert_int(labels.size()).override_failure_message("3 options must render as 3 labels").is_equal(3)
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
# MOUSE_FILTER_STOP = 0: active and clickable — correct for D-062
|
||||||
|
# MOUSE_FILTER_IGNORE = 2: would indicate disabled — D-062 violation
|
||||||
|
assert_int(label.mouse_filter).override_failure_message(
|
||||||
|
"Option '%s' must be mouse_filter=STOP (0), not IGNORE (2) — D-062 requires no locked options" % label.text
|
||||||
|
).is_not_equal(Control.MOUSE_FILTER_IGNORE)
|
||||||
|
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d062_no_lock_icon_children_on_options() -> void:
|
||||||
|
## D-062: Options must have no lock icon children (no TextureRect/Sprite2D children).
|
||||||
|
## Any child node on an option label would indicate a locked-option indicator.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Only option"]))
|
||||||
|
|
||||||
|
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||||
|
if options_container == null: box.queue_free(); return
|
||||||
|
|
||||||
|
var labels := options_container.get_children()
|
||||||
|
assert_int(labels.size()).is_greater(0)
|
||||||
|
|
||||||
|
for label in labels:
|
||||||
|
assert_int(label.get_child_count()).override_failure_message(
|
||||||
|
"Option label must have no child nodes — no lock icons, no decorators (D-062)"
|
||||||
|
).is_equal(0)
|
||||||
|
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d062_max_options_constant_is_three() -> void:
|
||||||
|
## D-061: max 3 response options. D-062: if >3 options arrive, only top 3 by priority
|
||||||
|
## are shown — server must omit locked ones, client only truncates to 3.
|
||||||
|
## Verify MAX_OPTIONS constant is locked at 3.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
assert_int(box.MAX_OPTIONS).override_failure_message(
|
||||||
|
"MAX_OPTIONS must be 3 per D-061 spec"
|
||||||
|
).is_equal(3)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d062_server_sends_four_options_only_three_render() -> void:
|
||||||
|
## D-061/D-062: If server sends 4 options (shouldn't happen but guard),
|
||||||
|
## only top 3 by priority render. No 4th option appears.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
var opts := _make_options(["A", "B", "C", "D"])
|
||||||
|
# Assign explicit priorities so sort is deterministic
|
||||||
|
for i in range(opts.size()):
|
||||||
|
opts[i]["priority"] = i
|
||||||
|
box.show_dialogue("NPC", "Speech.", opts)
|
||||||
|
|
||||||
|
var options_container := box.get_node_or_null("PanelContainer/MarginContainer/VBoxContainer/OptionsContainer")
|
||||||
|
if options_container == null: box.queue_free(); return
|
||||||
|
|
||||||
|
assert_int(options_container.get_child_count()).override_failure_message(
|
||||||
|
"Only 3 options must render even when server sends 4 (D-061 truncation)"
|
||||||
|
).is_equal(3)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-063: Confrontation beat
|
||||||
|
## Confrontation options trigger a 1.5s pre-delivery monologue beat.
|
||||||
|
## Panel dims during beat. confrontation_monologue signal fires.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_d063_beat_duration_within_spec() -> void:
|
||||||
|
## D-063: The beat duration must be 1–2 seconds per spec.
|
||||||
|
## Current implementation: CONFRONTATION_BEAT_DURATION = 1.5s.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
assert_float(box.CONFRONTATION_BEAT_DURATION).override_failure_message(
|
||||||
|
"D-063: confrontation beat must be 1.0–2.0 seconds"
|
||||||
|
).is_between(1.0, 2.0)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d063_dim_alpha_is_set() -> void:
|
||||||
|
## D-063: The dialogue box dims during the confrontation beat.
|
||||||
|
## CONFRONTATION_DIM_ALPHA must be below 1.0 (not full opacity).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||||
|
"D-063: confrontation dim alpha must be < 1.0 (panel visibly dims)"
|
||||||
|
).is_less(1.0)
|
||||||
|
assert_float(box.CONFRONTATION_DIM_ALPHA).override_failure_message(
|
||||||
|
"D-063: confrontation dim alpha must be > 0.0 (panel still visible)"
|
||||||
|
).is_greater(0.0)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||||
|
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||||
|
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
var signal_fired := false
|
||||||
|
var received_text := ""
|
||||||
|
box.confrontation_monologue.connect(func(text: String, _dur: float):
|
||||||
|
signal_fired = true
|
||||||
|
received_text = text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show dialogue with one confrontation option
|
||||||
|
var opts := _make_options(["I know what you did."], [true])
|
||||||
|
box.show_dialogue("NPC", "Everything is fine.", opts)
|
||||||
|
|
||||||
|
# Press option 1 (index 0)
|
||||||
|
box._on_option_pressed(0)
|
||||||
|
|
||||||
|
assert_bool(signal_fired).override_failure_message(
|
||||||
|
"D-063: confrontation_monologue signal must fire when confrontation option is selected"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d063_non_confrontation_option_does_not_fire_beat_signal() -> void:
|
||||||
|
## D-063: Standard (non-confrontation) options must NOT fire confrontation_monologue.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
var signal_fired := false
|
||||||
|
box.confrontation_monologue.connect(func(_text: String, _dur: float):
|
||||||
|
signal_fired = true
|
||||||
|
)
|
||||||
|
|
||||||
|
var opts := _make_options(["A normal response."], [false])
|
||||||
|
box.show_dialogue("NPC", "Hello.", opts)
|
||||||
|
box._on_option_pressed(0)
|
||||||
|
|
||||||
|
assert_bool(signal_fired).override_failure_message(
|
||||||
|
"D-063: confrontation_monologue must NOT fire for standard options"
|
||||||
|
).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-064: Walk-away mechanic
|
||||||
|
## WASD during active conversation fires dialogue_dismissed signal.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_d064_walk_away_actions_constant_not_empty() -> void:
|
||||||
|
## D-064: _WALK_AWAY_ACTIONS must include at least the 8 movement directions.
|
||||||
|
## Prevents accidental empty-array regression.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
assert_int(box._WALK_AWAY_ACTIONS.size()).override_failure_message(
|
||||||
|
"D-064: _WALK_AWAY_ACTIONS must list movement directions (minimum 4)"
|
||||||
|
).is_greater_equal(4)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d064_walk_away_actions_include_cardinal_directions() -> void:
|
||||||
|
## D-064: All four cardinal directions (WASD) must be walk-away triggers.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
var actions: Array = box._WALK_AWAY_ACTIONS
|
||||||
|
for required in [&"move_north", &"move_south", &"move_east", &"move_west"]:
|
||||||
|
assert_bool(required in actions).override_failure_message(
|
||||||
|
"D-064: '%s' must be in _WALK_AWAY_ACTIONS" % required
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d064_dialogue_dismissed_signal_connection() -> void:
|
||||||
|
## D-064: dialogue_dismissed signal must exist on DialogueBox.
|
||||||
|
## The signal drives walk-away behavior in main.gd.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
assert_bool(box.has_signal("dialogue_dismissed")).override_failure_message(
|
||||||
|
"D-064: dialogue_dismissed signal must exist on DialogueBox"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState: current_dialogue snapshot parsing
|
||||||
|
## D-061: current_dialogue is set from snapshot, null when absent.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_current_dialogue_set_from_snapshot() -> void:
|
||||||
|
## apply_snapshot with current_dialogue dict populates the field.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"current_dialogue": {
|
||||||
|
"npc_name": "Kael Davan",
|
||||||
|
"npc_entity_id": 42,
|
||||||
|
"speech": "You don't belong here.",
|
||||||
|
"options": [{"text": "I'm just passing through.", "response_id": "r001", "priority": 1}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_dialogue).is_not_null()
|
||||||
|
assert_that(GameState.current_dialogue.get("npc_name")).is_equal("Kael Davan")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_current_dialogue_null_when_absent() -> void:
|
||||||
|
## apply_snapshot without current_dialogue clears the field.
|
||||||
|
## Prevents stale dialogue from persisting across ticks.
|
||||||
|
GameState.current_dialogue = {"npc_name": "Ghost", "speech": "Stale."}
|
||||||
|
GameState.apply_snapshot({"tick": 2})
|
||||||
|
assert_that(GameState.current_dialogue).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_current_dialogue_null_when_non_dict() -> void:
|
||||||
|
## Non-dict current_dialogue is rejected — defensive against malformed server data.
|
||||||
|
GameState.apply_snapshot({"tick": 1, "current_dialogue": "not-a-dict"})
|
||||||
|
assert_that(GameState.current_dialogue).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_current_dialogue_options_survive_roundtrip() -> void:
|
||||||
|
## The options array must survive snapshot parsing for DialogueBox to render them.
|
||||||
|
var options := [
|
||||||
|
{"text": "A", "response_id": "r1", "priority": 1},
|
||||||
|
{"text": "B", "response_id": "r2", "priority": 2},
|
||||||
|
]
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"current_dialogue": {
|
||||||
|
"npc_name": "NPC",
|
||||||
|
"npc_entity_id": 1,
|
||||||
|
"speech": "Choose.",
|
||||||
|
"options": options,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_dialogue).is_not_null()
|
||||||
|
var parsed_opts: Array = GameState.current_dialogue.get("options", [])
|
||||||
|
assert_int(parsed_opts.size()).is_equal(2)
|
||||||
|
assert_that(parsed_opts[0].get("response_id")).is_equal("r1")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState: current_examine_result snapshot parsing (test-first, #174)
|
||||||
|
## Sprint 18: examine verb returns character-filtered observation text.
|
||||||
|
## Field: examine_result: {entity_id: int, text: String, confidence: String} | null
|
||||||
|
## GameState must expose current_examine_result for the overlay display node.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_field_exists() -> void:
|
||||||
|
## GameState must have a current_examine_result field (Sprint 18, #174).
|
||||||
|
## Fails until Stig adds the field to game_state.gd.
|
||||||
|
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||||
|
"GameState must have 'current_examine_result' field (Sprint 18 #174 — add to game_state.gd)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_by_default() -> void:
|
||||||
|
## current_examine_result defaults to null (no examine active).
|
||||||
|
if not GameState.has("current_examine_result"):
|
||||||
|
push_warning("test_gamestate_examine_result_null_by_default: field not yet added — skip")
|
||||||
|
return
|
||||||
|
GameState.current_examine_result = null
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_set_from_snapshot() -> void:
|
||||||
|
## apply_snapshot with examine_result dict populates current_examine_result.
|
||||||
|
## Wire format (joint.md): {entity_id: int, text: String, confidence: String}
|
||||||
|
if not GameState.has("current_examine_result"):
|
||||||
|
push_warning("test_gamestate_examine_result_set_from_snapshot: field not yet added — skip")
|
||||||
|
return
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 5,
|
||||||
|
"examine_result": {
|
||||||
|
"entity_id": 12,
|
||||||
|
"text": "Kael Davan — nervous energy. He's scanning exits.",
|
||||||
|
"confidence": "KnowsOf",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_examine_result).is_not_null()
|
||||||
|
assert_that(GameState.current_examine_result.get("text")).contains("Kael Davan")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_when_absent() -> void:
|
||||||
|
## apply_snapshot without examine_result must clear the field.
|
||||||
|
## Prevents stale examine overlay persisting beyond auto-dismiss window.
|
||||||
|
if not GameState.has("current_examine_result"):
|
||||||
|
push_warning("test_gamestate_examine_result_null_when_absent: field not yet added — skip")
|
||||||
|
return
|
||||||
|
GameState.current_examine_result = {"entity_id": 5, "text": "Stale.", "confidence": "Suspects"}
|
||||||
|
GameState.apply_snapshot({"tick": 6})
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_null_when_non_dict() -> void:
|
||||||
|
## Malformed examine_result (not a dict) must be rejected.
|
||||||
|
if not GameState.has("current_examine_result"):
|
||||||
|
push_warning("test_gamestate_examine_result_null_when_non_dict: field not yet added — skip")
|
||||||
|
return
|
||||||
|
GameState.apply_snapshot({"tick": 1, "examine_result": "bad-value"})
|
||||||
|
assert_that(GameState.current_examine_result).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||||
|
## entity_id is needed to anchor the overlay above the correct entity.
|
||||||
|
if not GameState.has("current_examine_result"):
|
||||||
|
push_warning("test_gamestate_examine_result_entity_id_survives_roundtrip: field not yet added — skip")
|
||||||
|
return
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"examine_result": {"entity_id": 99, "text": "Observed.", "confidence": "Direct"},
|
||||||
|
})
|
||||||
|
assert_int(GameState.current_examine_result.get("entity_id", -1)).is_equal(99)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BBCode injection guard (regression: Hoshe #2)
|
||||||
|
## Server-sourced text containing BBCode brackets must be escaped.
|
||||||
|
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_escape_bbcode_brackets_in_server_text() -> void:
|
||||||
|
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
|
||||||
|
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
|
||||||
|
## as plain text in the dialogue log.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
|
||||||
|
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
|
||||||
|
assert_that(escaped).contains("[lb]")
|
||||||
|
assert_bool(escaped.begins_with("[")).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_escape_bbcode_plain_text_unchanged() -> void:
|
||||||
|
## Non-BBCode text must not be modified by _escape_bbcode.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var plain := "Kael Davan"
|
||||||
|
assert_that(box._escape_bbcode(plain)).is_equal(plain)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_escape_bbcode_multiple_brackets() -> void:
|
||||||
|
## Multiple '[' characters all get escaped.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
var text := "[b]Bold[/b] and [i]italic[/i]"
|
||||||
|
var escaped: String = box._escape_bbcode(text)
|
||||||
|
assert_bool(escaped.contains("[b]")).is_false()
|
||||||
|
assert_bool(escaped.contains("[i]")).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log dirty flag optimization (regression: Hoshe #1)
|
||||||
|
## _log_dirty prevents per-frame O(n) BBCode rebuilds.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_log_dirty_false_on_init() -> void:
|
||||||
|
## _log_dirty starts false — no rebuild needed before first line.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_bool(box._log_dirty).override_failure_message(
|
||||||
|
"_log_dirty must be false on init — no unnecessary rebuild"
|
||||||
|
).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_log_dirty_set_after_append_line() -> void:
|
||||||
|
## Appending a line sets _log_dirty = true.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
box.append_line("NPC", "Player", "Hello.", false)
|
||||||
|
assert_bool(box._log_dirty).override_failure_message(
|
||||||
|
"_log_dirty must be true after append_line to trigger rebuild next _process"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_log_dirty_cleared_after_process() -> void:
|
||||||
|
## After _process(), _log_dirty is cleared (rebuild done).
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
box.append_line("NPC", "Player", "One line.", false)
|
||||||
|
assert_bool(box._log_dirty).is_true()
|
||||||
|
box._process(0.0)
|
||||||
|
assert_bool(box._log_dirty).override_failure_message(
|
||||||
|
"_log_dirty must be false after _process (rebuild consumed the flag)"
|
||||||
|
).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-061: Dialogue box size constraints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_d061_max_height_ratio_is_twenty_percent() -> void:
|
||||||
|
## D-061: dialogue box must occupy max 20% viewport height.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_float(box.MAX_HEIGHT_RATIO).override_failure_message(
|
||||||
|
"D-061: MAX_HEIGHT_RATIO must be 0.2 (20% viewport height)"
|
||||||
|
).is_equal_approx(0.2, 0.001)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d061_fade_in_is_200ms() -> void:
|
||||||
|
## D-061: fade-in on dialogue appearance is 0.2s.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_float(box.FADE_IN).override_failure_message(
|
||||||
|
"D-061: FADE_IN must be 0.2s"
|
||||||
|
).is_equal_approx(0.2, 0.001)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d064_fade_out_is_300ms() -> void:
|
||||||
|
## D-064: walk-away fade-out is 300ms per spec.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_float(box.FADE_OUT).override_failure_message(
|
||||||
|
"D-064: FADE_OUT must be 0.3s (300ms walk-away fade)"
|
||||||
|
).is_equal_approx(0.3, 0.001)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Passive log entry (D-078: overheard NPC-NPC)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_passive_entry_uses_bar_glyph() -> void:
|
||||||
|
## D-078: Overheard NPC-NPC lines get ┃ prefix (PASSIVE_GLYPH).
|
||||||
|
## Verifies the glyph constant is the correct Unicode bar character.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_that(box.PASSIVE_GLYPH).override_failure_message(
|
||||||
|
"D-078: PASSIVE_GLYPH must be ┃ (U+2503) + space"
|
||||||
|
).is_equal("\u2503 ")
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_passive_entry_appended_and_marked_is_passive() -> void:
|
||||||
|
## D-078: append_conversation_event creates a passive log entry.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
var event := {
|
||||||
|
"speaker_id": 10, "target_id": 11,
|
||||||
|
"speaker_name": "Guard A", "target_name": "Guard B",
|
||||||
|
"occluded_line": "Did you see the detective?",
|
||||||
|
"speaker_color_index": 0, "target_color_index": 1,
|
||||||
|
}
|
||||||
|
box.append_conversation_event(event)
|
||||||
|
|
||||||
|
assert_int(box._log_entries.size()).is_equal(1)
|
||||||
|
assert_bool(box._log_entries[0].is_passive).override_failure_message(
|
||||||
|
"D-078: overheard entry must be marked is_passive = true"
|
||||||
|
).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_passive_entry_empty_text_not_appended() -> void:
|
||||||
|
## D-078: Conversation event with empty occluded_line is silently dropped.
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
|
||||||
|
box.append_conversation_event({
|
||||||
|
"speaker_id": 1, "target_id": 2,
|
||||||
|
"speaker_name": "A", "target_name": "B",
|
||||||
|
"occluded_line": "",
|
||||||
|
})
|
||||||
|
assert_int(box._log_entries.size()).override_failure_message(
|
||||||
|
"D-078: empty occluded_line must not produce a log entry"
|
||||||
|
).is_equal(0)
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_active_entries / is_dialogue_active
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_has_active_entries_false_on_init() -> void:
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_bool(box.has_active_entries()).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_has_active_entries_true_after_append() -> void:
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
box.append_line("NPC", "Player", "Hey.", false)
|
||||||
|
assert_bool(box.has_active_entries()).is_true()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_is_dialogue_active_false_on_init() -> void:
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
assert_bool(box.is_dialogue_active()).is_false()
|
||||||
|
box.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_is_dialogue_active_true_after_show_dialogue() -> void:
|
||||||
|
var box := _make_dialogue_box()
|
||||||
|
if box == null: return
|
||||||
|
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||||
|
assert_bool(box.is_dialogue_active()).is_true()
|
||||||
|
box.queue_free()
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
|
||||||
|
## Tests for relationship color indicator in interaction_list.gd.
|
||||||
|
##
|
||||||
|
## Spec refs:
|
||||||
|
## D-033 (entity color = relationship to player)
|
||||||
|
## D-051 (diegetic insert display — insert-only overlay)
|
||||||
|
## D-057 (interaction list, z-layer 6)
|
||||||
|
##
|
||||||
|
## Phase 1 scope (2026-02-24):
|
||||||
|
## Color bar only — NPC name and dialogue tier deferred to Phase 2 pending
|
||||||
|
## server protocol change (no known_attributes in wire protocol v13).
|
||||||
|
##
|
||||||
|
## Implementation: client/ui/interaction_list.gd
|
||||||
|
## `var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM`
|
||||||
|
## `func _cache_entity_relationship() -> void`
|
||||||
|
## 3px left-edge bar drawn in _draw() at 85% alpha, called from update_from_state()
|
||||||
|
class_name TestETalkOverlaySprint17
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.player_stance = ""
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.player_stance = ""
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-033: Constants.color_for_relationship() — palette baseline (pure logic)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_relationship_unknown_maps_to_teal() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
func test_relationship_friendly_maps_to_green() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
|
|
||||||
|
func test_relationship_poi_maps_to_amber() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
|
|
||||||
|
func test_relationship_hostile_maps_to_red() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
|
||||||
|
func test_relationship_known_falls_back_to_unknown_color() -> void:
|
||||||
|
# "Known" not matched in color_for_relationship() — falls through _ → ENTITY_COLOR_UNKNOWN
|
||||||
|
assert_that(Constants.color_for_relationship("Known")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
func test_relationship_unknown_string_falls_back_to_unknown() -> void:
|
||||||
|
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_unknown_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_known_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_friendly_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_poi_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
|
||||||
|
|
||||||
|
func test_ui_strings_has_relationship_hostile_label() -> void:
|
||||||
|
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# _relationship_color initial state
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_relationship_color_default_is_implant_dim() -> void:
|
||||||
|
# Before any update, _relationship_color starts at IMPLANT_TEXT_DIM
|
||||||
|
var list = _make_list()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# _cache_entity_relationship(): D-033 palette via update_from_state() (#537 core)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_cache_relationship_unknown_sets_unknown_color() -> void:
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_friendly_sets_green() -> void:
|
||||||
|
_setup_npc_interaction(2, "Friendly")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_poi_sets_amber() -> void:
|
||||||
|
_setup_npc_interaction(2, "PersonOfInterest")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_hostile_sets_red() -> void:
|
||||||
|
_setup_npc_interaction(2, "Hostile")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_absent_field_uses_unknown_color() -> void:
|
||||||
|
# Entity present but "relationship" key missing → get("relationship", "Unknown") → Unknown color
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward",
|
||||||
|
}]
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_entity_not_in_visible_uses_dim_fallback() -> void:
|
||||||
|
# Entity in nearby_interactions but NOT in visible_entities → IMPLANT_TEXT_DIM fallback
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_updates_when_relationship_changes() -> void:
|
||||||
|
# First call: Unknown
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
|
|
||||||
|
# Relationship shifts → re-cache picks up new value
|
||||||
|
_setup_npc_interaction(2, "Hostile")
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_cache_relationship_targets_correct_entity_by_id() -> void:
|
||||||
|
# Two entities visible; nearby_interactions[0] is the target (entity 2, Friendly)
|
||||||
|
# Entity 3 (Hostile) must not pollute the color
|
||||||
|
GameState.visible_entities = [
|
||||||
|
{
|
||||||
|
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
GameState.nearby_interactions = [
|
||||||
|
{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": 3, "entity_type": "Npc", "distance": 2,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
# interaction[0] = entity 2 (Friendly) → green bar
|
||||||
|
assert_that(list._relationship_color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_z_layer_is_insert_canvas() -> void:
|
||||||
|
# D-049 / D-057: interaction list lives on InsertOverlay (CanvasLayer 10)
|
||||||
|
var list = _make_list()
|
||||||
|
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Regression: interaction_list public API unaffected by #537 changes
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_list_hides_when_no_interactions() -> void:
|
||||||
|
GameState.nearby_interactions = []
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_false()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_shows_on_npc_interaction() -> void:
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_true()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_get_selected_verb_returns_first_verb() -> void:
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [
|
||||||
|
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||||
|
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
assert_that(list.get_selected_verb()).is_equal("Talk")
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_get_interaction_target_returns_entity_id() -> void:
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": 5, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
|
||||||
|
}]
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 5, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.get_interaction_target()).is_equal(5)
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_suppressed_when_insert_inactive() -> void:
|
||||||
|
_setup_npc_interaction(2, "Unknown")
|
||||||
|
var list = _make_list()
|
||||||
|
list.set_insert_active(false)
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_false()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
func test_list_hides_on_empty_verb_list() -> void:
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
|
||||||
|
}]
|
||||||
|
var list = _make_list()
|
||||||
|
list.update_from_state()
|
||||||
|
assert_that(list.is_showing()).is_false()
|
||||||
|
list.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Phase 2 placeholders — deferred pending server protocol change (no known_attributes in v13)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func skip_test_npc_name_displayed_when_known() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
func skip_test_dialogue_tier_context_hint_for_friendly() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
func skip_test_dialogue_tier_context_hint_for_hostile() -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _make_list() -> Control:
|
||||||
|
var scene = load("res://ui/interaction_list.tscn")
|
||||||
|
var list = scene.instantiate()
|
||||||
|
add_child(list) # _ready() fires here — @onready var _vbox resolves
|
||||||
|
return list
|
||||||
|
|
||||||
|
|
||||||
|
## Set up GameState with a single NPC entity + matching interaction for tests.
|
||||||
|
func _setup_npc_interaction(entity_id: int, relationship: String) -> void:
|
||||||
|
GameState.visible_entities = [{
|
||||||
|
"entity_id": entity_id, "x": 12.0, "y": 9.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null},
|
||||||
|
"visibility": "Forward", "relationship": relationship, "observation": "Visible",
|
||||||
|
}]
|
||||||
|
GameState.nearby_interactions = [{
|
||||||
|
"entity_id": entity_id, "entity_type": "Npc", "distance": 1,
|
||||||
|
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
|
||||||
|
}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmalit26eiwlh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://6q82kiijx7ml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8ng0ubdfumht
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
## Sprint 18 — Knowledge/journal display (#264)
|
||||||
|
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
|
||||||
|
## D-042 (UIStrings for all labels)
|
||||||
|
##
|
||||||
|
## Tests now run against live Stig implementation.
|
||||||
|
## Wire format per game_state.gd v14:
|
||||||
|
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||||
|
##
|
||||||
|
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||||
|
## The server sends KG updates only when the graph changes — absence = no change.
|
||||||
|
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||||
|
class_name TestJournalSprint18
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
|
||||||
|
|
||||||
|
func _make_journal_panel() -> Control:
|
||||||
|
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
|
||||||
|
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
|
||||||
|
return null
|
||||||
|
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
|
||||||
|
add_child(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||||
|
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||||
|
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||||
|
var base: Dictionary = {
|
||||||
|
"entity_id": 42,
|
||||||
|
"name": "Kael Davan",
|
||||||
|
"confidence": "KnowsOf",
|
||||||
|
"source": "DirectObservation",
|
||||||
|
"state": "Active",
|
||||||
|
"relationship": "PersonOfInterest",
|
||||||
|
"last_observed_tick": 1024,
|
||||||
|
}
|
||||||
|
base.merge(overrides, true)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||||
|
if entities.is_empty():
|
||||||
|
entities = [_make_kg_entity()]
|
||||||
|
return {"entities": entities}
|
||||||
|
|
||||||
|
|
||||||
|
func _entries_container(panel: Control) -> Node:
|
||||||
|
return panel.get_node_or_null(
|
||||||
|
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.player_knowledge = null
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
GameState.current_tick = 0
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.player_knowledge = null
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GameState: player_knowledge snapshot parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_field_exists() -> void:
|
||||||
|
## GameState must have player_knowledge field (v14, #264).
|
||||||
|
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||||
|
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_null_by_default() -> void:
|
||||||
|
GameState.player_knowledge = null
|
||||||
|
assert_that(GameState.player_knowledge).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 10,
|
||||||
|
"player_knowledge": _make_player_knowledge(),
|
||||||
|
})
|
||||||
|
assert_that(GameState.player_knowledge).is_not_null()
|
||||||
|
assert_bool(GameState.player_knowledge.has("entities")).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||||
|
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||||
|
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||||
|
## This is intentional behavior (journal should not flash empty every tick).
|
||||||
|
GameState.player_knowledge = _make_player_knowledge()
|
||||||
|
GameState.apply_snapshot({"tick": 11})
|
||||||
|
assert_that(GameState.player_knowledge).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
|
||||||
|
## Malformed player_knowledge (non-dict) must be rejected.
|
||||||
|
## First set a valid value, then try to overwrite with invalid
|
||||||
|
GameState.player_knowledge = _make_player_knowledge()
|
||||||
|
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||||
|
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||||
|
# The implementation only updates on Dictionary type, so value persists
|
||||||
|
assert_that(GameState.player_knowledge).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||||
|
var entities := [
|
||||||
|
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||||
|
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||||
|
]
|
||||||
|
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||||
|
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||||
|
assert_int(parsed_entities.size()).is_equal(2)
|
||||||
|
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||||
|
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||||
|
|
||||||
|
|
||||||
|
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||||
|
## When server sends a new player_knowledge, it replaces the previous value.
|
||||||
|
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||||
|
_make_kg_entity({"name": "Person A"}),
|
||||||
|
])})
|
||||||
|
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||||
|
_make_kg_entity({"name": "Person A"}),
|
||||||
|
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||||
|
])})
|
||||||
|
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||||
|
assert_int(entities.size()).is_equal(2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Journal panel: scene and API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_journal_panel_scene_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
|
||||||
|
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_instantiates_without_crash() -> void:
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_that(panel).is_not_null()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_has_toggle_method() -> void:
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||||
|
"JournalPanel must have toggle() method"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_has_close_method() -> void:
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.has_method("close")).override_failure_message(
|
||||||
|
"JournalPanel must have close() method"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_has_is_open_method() -> void:
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||||
|
"JournalPanel must have is_open() method"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_has_update_from_state_method() -> void:
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||||
|
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_closed_on_init() -> void:
|
||||||
|
## Panel starts hidden — not open by default.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.is_open()).override_failure_message(
|
||||||
|
"JournalPanel must be closed on _ready()"
|
||||||
|
).is_false()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_toggle_opens() -> void:
|
||||||
|
## First toggle() opens the panel.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
panel.toggle()
|
||||||
|
assert_bool(panel.is_open()).override_failure_message(
|
||||||
|
"toggle() must set is_open() = true"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_toggle_closes() -> void:
|
||||||
|
## Second toggle() closes the panel.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
panel.toggle() # open
|
||||||
|
panel.toggle() # close
|
||||||
|
assert_bool(panel.is_open()).override_failure_message(
|
||||||
|
"Second toggle() must close the panel"
|
||||||
|
).is_false()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||||
|
## close() on an already-closed panel must not crash.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
panel.close()
|
||||||
|
assert_bool(panel.is_open()).is_false()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Journal panel: entry rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_journal_panel_entries_container_exists() -> void:
|
||||||
|
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
var container := _entries_container(panel)
|
||||||
|
assert_that(container != null).override_failure_message(
|
||||||
|
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||||
|
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
GameState.player_knowledge = _make_player_knowledge([
|
||||||
|
_make_kg_entity({"name": "Kael Davan"}),
|
||||||
|
])
|
||||||
|
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||||
|
|
||||||
|
var container := _entries_container(panel)
|
||||||
|
if container == null: panel.queue_free(); return
|
||||||
|
|
||||||
|
assert_int(container.get_child_count()).override_failure_message(
|
||||||
|
"EntriesContainer must have children when player_knowledge is populated"
|
||||||
|
).is_greater(0)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||||
|
## Empty state Label is shown when player_knowledge is null.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
GameState.player_knowledge = null
|
||||||
|
panel.toggle()
|
||||||
|
|
||||||
|
var container := _entries_container(panel)
|
||||||
|
if container == null: panel.queue_free(); return
|
||||||
|
|
||||||
|
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||||
|
assert_int(container.get_child_count()).override_failure_message(
|
||||||
|
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||||
|
).is_equal(1)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||||
|
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
GameState.player_knowledge = _make_player_knowledge([
|
||||||
|
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||||
|
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||||
|
])
|
||||||
|
panel.toggle()
|
||||||
|
|
||||||
|
var container := _entries_container(panel)
|
||||||
|
if container == null: panel.queue_free(); return
|
||||||
|
|
||||||
|
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||||
|
assert_int(container.get_child_count()).override_failure_message(
|
||||||
|
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||||
|
).is_greater_equal(6)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||||
|
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||||
|
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
GameState.player_knowledge = _make_player_knowledge([
|
||||||
|
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||||
|
])
|
||||||
|
panel.toggle()
|
||||||
|
|
||||||
|
var container := _entries_container(panel)
|
||||||
|
if container == null: panel.queue_free(); return
|
||||||
|
|
||||||
|
## First child should be the header RichTextLabel with [s]...[/s]
|
||||||
|
if container.get_child_count() == 0:
|
||||||
|
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||||
|
panel.queue_free(); return
|
||||||
|
|
||||||
|
var first_child := container.get_child(0)
|
||||||
|
if first_child is RichTextLabel:
|
||||||
|
assert_that(first_child.text).override_failure_message(
|
||||||
|
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||||
|
).contains("[s]")
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||||
|
## Active entity must NOT have strikethrough in its header.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
GameState.player_knowledge = _make_player_knowledge([
|
||||||
|
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||||
|
])
|
||||||
|
panel.toggle()
|
||||||
|
|
||||||
|
var container := _entries_container(panel)
|
||||||
|
if container == null: panel.queue_free(); return
|
||||||
|
|
||||||
|
if container.get_child_count() == 0:
|
||||||
|
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||||
|
panel.queue_free(); return
|
||||||
|
|
||||||
|
var first_child := container.get_child(0)
|
||||||
|
if first_child is RichTextLabel:
|
||||||
|
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||||
|
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||||
|
).is_false()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Journal panel: mutual exclusion with dialogue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||||
|
## Sprint briefing: journal must close when dialogue opens.
|
||||||
|
## update_from_state() is called from main.gd on each snapshot.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
panel.toggle() # open journal
|
||||||
|
assert_bool(panel.is_open()).is_true()
|
||||||
|
|
||||||
|
GameState.dialogue_active = true
|
||||||
|
panel.update_from_state()
|
||||||
|
|
||||||
|
assert_bool(panel.is_open()).override_failure_message(
|
||||||
|
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||||
|
).is_false()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||||
|
## update_from_state() must NOT close journal when dialogue is not active.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
|
||||||
|
panel.toggle() # open journal
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
panel.update_from_state()
|
||||||
|
|
||||||
|
assert_bool(panel.is_open()).override_failure_message(
|
||||||
|
"Journal must stay open when dialogue is inactive"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
|
||||||
|
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
|
||||||
|
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_ui_strings_confidence_direct_exists() -> void:
|
||||||
|
## D-042: confidence label for "Direct" tier must be in UIStrings.
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_confidence_knowsdetails_exists() -> void:
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_confidence_knowsof_exists() -> void:
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_confidence_suspects_exists() -> void:
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_all_confidence_keys_non_empty() -> void:
|
||||||
|
## All four confidence label values must be non-empty strings.
|
||||||
|
var keys := [
|
||||||
|
"knowledge_panel.confidence_direct",
|
||||||
|
"knowledge_panel.confidence_knowsdetails",
|
||||||
|
"knowledge_panel.confidence_knowsof",
|
||||||
|
"knowledge_panel.confidence_suspects",
|
||||||
|
]
|
||||||
|
for key in keys:
|
||||||
|
if not UIStrings.has_key(key): continue
|
||||||
|
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
|
||||||
|
"UIStrings key '%s' must be non-empty" % key
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_source_directobservation_exists() -> void:
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_source_toldby_exists() -> void:
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_source_heard_exists() -> void:
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Journal panel: _state_color() contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_state_color_contradicted_is_amber() -> void:
|
||||||
|
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
var color: Color = panel._state_color("Contradicted")
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||||
|
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_state_color_stale_is_dimmed() -> void:
|
||||||
|
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
var color: Color = panel._state_color("Stale")
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||||
|
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_state_color_active_is_normal() -> void:
|
||||||
|
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
var color: Color = panel._state_color("Active")
|
||||||
|
assert_that(color).override_failure_message(
|
||||||
|
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||||
|
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_state_color_contradicted_differs_from_active() -> void:
|
||||||
|
## Contradicted and Active must have visually distinct colors.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
var contradicted := panel._state_color("Contradicted")
|
||||||
|
var active := panel._state_color("Active")
|
||||||
|
assert_that(contradicted).is_not_equal(active)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_state_color_stale_differs_from_active() -> void:
|
||||||
|
## Stale and Active must have visually distinct colors.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
var stale := panel._state_color("Stale")
|
||||||
|
var active := panel._state_color("Active")
|
||||||
|
assert_that(stale).is_not_equal(active)
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UIStrings: knowledge_panel keys (D-042)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||||
|
## Journal title uses knowledge_panel.tab_contacts.
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
|
||||||
|
## Empty state message uses knowledge_panel.empty_state.
|
||||||
|
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||||
|
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||||
|
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||||
|
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||||
|
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||||
|
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
|
||||||
|
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
|
||||||
|
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
|
||||||
|
## Regression guard: verify the dicts are gone and UIStrings fallback works.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
|
||||||
|
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||||
|
## If this test fails, the hardcoded dict was accidentally re-introduced.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
|
||||||
|
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
|
||||||
|
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
|
||||||
|
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
|
||||||
|
).is_true()
|
||||||
|
panel.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
|
||||||
|
## UIStrings falls back to the key string itself for missing keys.
|
||||||
|
## journal_panel.gd relies on this for graceful degradation.
|
||||||
|
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
|
||||||
|
assert_that(fallback).override_failure_message(
|
||||||
|
"UIStrings fallback must return the key string itself for unknown keys"
|
||||||
|
).is_equal("knowledge_panel.confidence_nonexistent_level")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_canvas_insert_constant_is_10() -> void:
|
||||||
|
assert_int(Constants.CANVAS_INSERT).is_equal(10)
|
||||||
|
|
||||||
|
|
||||||
|
func test_journal_fade_constants_reasonable() -> void:
|
||||||
|
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||||
|
var panel := _make_journal_panel()
|
||||||
|
if panel == null: return
|
||||||
|
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||||
|
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||||
|
panel.queue_free()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bn4a46egt2uol
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
## Sprint 15 — Smooth camera movement tests (#117)
|
||||||
|
## Validates exponential lerp, teleport snap, and configurable smoothing.
|
||||||
|
## Spec: D-015 (camera locked, fixed-north), #117 (interpolated tracking).
|
||||||
|
class_name TestSmoothCameraSprint15
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.current_tick = 0
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.current_monologue = null
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
if _instance and is_instance_valid(_instance):
|
||||||
|
_instance.queue_free()
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
|
||||||
|
# --- Configurable smoothing constant ---
|
||||||
|
|
||||||
|
func test_camera_smoothing_speed_constant_defined() -> void:
|
||||||
|
# #117: CAMERA_SMOOTHING_SPEED must be declared in Constants (configurable).
|
||||||
|
assert_that(Constants.CAMERA_SMOOTHING_SPEED > 0.0).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_smoothing_speed_constant_reasonable() -> void:
|
||||||
|
# #117: Speed should produce smooth-but-responsive feel (2.0–20.0 range).
|
||||||
|
assert_that(
|
||||||
|
Constants.CAMERA_SMOOTHING_SPEED >= 2.0 and Constants.CAMERA_SMOOTHING_SPEED <= 20.0
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Manual lerp, no Godot built-in smoothing ---
|
||||||
|
|
||||||
|
func test_godot_smoothing_disabled_at_ready() -> void:
|
||||||
|
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.position_smoothing_enabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||||
|
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.position_smoothing_enabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interpolated tracking (no snap) ---
|
||||||
|
|
||||||
|
func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||||
|
# #117: When player moves, camera should lerp (not snap) to new position.
|
||||||
|
# After 1 frame at ~60fps, camera should be partway there — not at target.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
var start_y := camera.global_position.y # anchored at player (10,10) → 320px
|
||||||
|
|
||||||
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# Player moved to (10,9) → target_y = 288. Camera should be between 288 and 320.
|
||||||
|
var target_y: float = GameState.player_position.y * Constants.TILE_SIZE
|
||||||
|
assert_that(camera.global_position.y < start_y).is_true()
|
||||||
|
assert_that(camera.global_position.y > target_y).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||||
|
# #117: After enough frames the camera should be within 1px of target.
|
||||||
|
# At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
|
_instance._process(0.016) # trigger the move, get new player position
|
||||||
|
|
||||||
|
var target := GameState.player_position * Constants.TILE_SIZE
|
||||||
|
|
||||||
|
# Run 120 frames (~2s at 60fps) — converges within 1px for any speed ≥ 2.0
|
||||||
|
for i in range(120):
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
var dist := camera.global_position.distance_to(target)
|
||||||
|
assert_that(dist < 1.0).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_stationary_player_no_drift() -> void:
|
||||||
|
# #117: When player is stationary, camera should not drift (lerp to same point).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
var initial_pos := camera.global_position
|
||||||
|
|
||||||
|
# Run several frames with no movement
|
||||||
|
for i in range(10):
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# Camera should still be at anchored position (target = same point).
|
||||||
|
# Use distance check — lerp toward same point may introduce float rounding.
|
||||||
|
assert_that(camera.global_position.distance_to(initial_pos) < 0.01).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Teleport snap ---
|
||||||
|
|
||||||
|
func test_teleport_snaps_camera_immediately() -> void:
|
||||||
|
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
|
||||||
|
# Manually displace camera, set the flag, call _process — camera should snap to target.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
|
||||||
|
# Displace camera from its anchored position
|
||||||
|
camera.global_position = Vector2(0, 0)
|
||||||
|
# Set teleport flag — next _process() should snap to player target
|
||||||
|
_instance._camera_anchored = true
|
||||||
|
_instance._teleport_in_progress = true
|
||||||
|
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# Camera must now be exactly at player position (snapshot puts player at 10,10 → 320,320)
|
||||||
|
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||||
|
assert_that(camera.global_position).is_equal(expected)
|
||||||
|
|
||||||
|
|
||||||
|
func test_teleport_flag_cleared_after_snap() -> void:
|
||||||
|
# #117: _teleport_in_progress must be false after the snap frame.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
_instance._camera_anchored = true
|
||||||
|
_instance._teleport_in_progress = true
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
assert_that(_instance._teleport_in_progress).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_resumes_lerp_after_teleport() -> void:
|
||||||
|
# #117: Frame after teleport snap must resume lerp (not continue snapping).
|
||||||
|
# After teleport flag clears, any position delta produces lerp movement.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
# Frame 1: teleport snap — camera displaced, flag set, expect snap
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
camera.global_position = Vector2(0, 0)
|
||||||
|
_instance._camera_anchored = true
|
||||||
|
_instance._teleport_in_progress = true
|
||||||
|
_instance._process(0.016)
|
||||||
|
# After snap: camera at player position (10,10) = (320, 320)
|
||||||
|
var post_snap_y := camera.global_position.y
|
||||||
|
|
||||||
|
# Frame 2: player moves north — camera should lerp, not snap
|
||||||
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var new_target_y: float = GameState.player_position.y * Constants.TILE_SIZE
|
||||||
|
# Camera must be between snap position and new target (lerping, not snapping)
|
||||||
|
assert_that(camera.global_position.y < post_snap_y).is_true()
|
||||||
|
assert_that(camera.global_position.y > new_target_y).is_true()
|
||||||
|
# Teleport flag must not be re-set by normal movement
|
||||||
|
assert_that(_instance._teleport_in_progress).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# --- D-015: Fixed-north camera ---
|
||||||
|
|
||||||
|
func test_camera_no_rotation() -> void:
|
||||||
|
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.rotation).is_equal(0.0)
|
||||||
|
|
||||||
|
_instance._process(0.016)
|
||||||
|
assert_that(camera.rotation).is_equal(0.0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://s15smoothcam1
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
## Sprint 16 #543: GameState.current_zone_id population tests.
|
||||||
|
## Verifies that apply_snapshot() correctly extracts zone_id from the player's
|
||||||
|
## current tile after the O(N) → O(1) refactor. Behavior must be identical
|
||||||
|
## before and after the change (net-zero behavioral change per ticket spec).
|
||||||
|
## Spec refs: D-073, #543, #529.
|
||||||
|
class_name TestSnapshotZoneId
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.current_zone_id = ""
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.visibility_sectors = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Happy path (S16-Z01) ------------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_populated_when_player_on_zone_tile() -> void:
|
||||||
|
# S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5).
|
||||||
|
# D-073: current_zone_id must be set to the server-authoritative zone_id.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"},
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
{"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"current_zone_id must match zone_id at player tile (5,5)"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
|
assert_that(GameState.current_zone_id.is_empty()).override_failure_message(
|
||||||
|
"current_zone_id must be non-empty when player is on a zone-tagged tile"
|
||||||
|
).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Missing zone_id field (S16-Z02) -------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_empty_when_tile_lacks_zone_field() -> void:
|
||||||
|
# S16-Z02: Player's tile exists but has no zone_id key → empty string.
|
||||||
|
# Server may send tiles without zone_id when tile is unzoned.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Tile without zone_id field → current_zone_id must default to empty string"
|
||||||
|
).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Player off-tile (S16-Z03) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_empty_when_player_not_on_any_tile() -> void:
|
||||||
|
# S16-Z03: Player at (99,99) but no tile at that position → empty string.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Player at (99,99) with no matching tile → current_zone_id must be empty"
|
||||||
|
).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Empty tiles (S16-Z04) -----------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_empty_on_empty_visible_tiles() -> void:
|
||||||
|
# S16-Z04: No visible tiles at all → empty string, no crash.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Empty visible_tiles → current_zone_id must be empty string (no crash)"
|
||||||
|
).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Correct tile selected among many (S16-Z05) --------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_selects_correct_tile_among_many() -> void:
|
||||||
|
# S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected.
|
||||||
|
# Tests that the lookup doesn't accidentally match a neighboring tile.
|
||||||
|
var tiles: Array = []
|
||||||
|
for tx in range(10):
|
||||||
|
for ty in range(10):
|
||||||
|
tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor",
|
||||||
|
"zone_id": "zone_%d_%d" % [tx, ty]})
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": tiles,
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Player at (6,4) must get zone_6_4, not a neighboring tile"
|
||||||
|
).is_equal("zone_6_4")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Zone transition (S16-Z06) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_updates_on_zone_transition() -> void:
|
||||||
|
# S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5).
|
||||||
|
# current_zone_id must update on each snapshot.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 2,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"After moving to (6,5), current_zone_id must update to zone_beta"
|
||||||
|
).is_equal("zone_beta")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Regression: visible_positions unaffected (S16-Z07) -----------------------
|
||||||
|
|
||||||
|
func test_visible_positions_unaffected_by_zone_id_refactor() -> void:
|
||||||
|
# S16-Z07: The O(1) refactor must not break visible_positions population.
|
||||||
|
# Both zone_id and visible_positions derive from the same visible_tiles loop —
|
||||||
|
# verify both are correctly populated after a single apply_snapshot().
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||||
|
"visible_positions must still contain (5,5) after zone_id refactor"
|
||||||
|
).is_true()
|
||||||
|
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"current_zone_id must be zone_alpha after same apply_snapshot call"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fractional player position (S16-Z08) --------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_uses_int_truncation_of_player_position() -> void:
|
||||||
|
# S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5).
|
||||||
|
# Server sends player coords as floats; zone lookup must truncate to tile index.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Player at (5.7,5.9) must match tile (5,5) — int() truncation applies"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Test-mode tiles key (S16-Z09) --------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_works_with_test_mode_tiles_key() -> void:
|
||||||
|
# S16-Z09: Test mode sends "tiles" key, not "visible_tiles".
|
||||||
|
# The O(1) refactor uses member visible_tiles (covers both paths).
|
||||||
|
# Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles
|
||||||
|
# block only, this test fails — catching the exact regression Tyre flagged.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Test-mode 'tiles' key must populate zone_id via member visible_tiles"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://tp8wpp6t8tvx
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
## Sprint 16 #540: Sprite integration tests.
|
||||||
|
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
|
||||||
|
## bounding box, sprite asset existence from #541, and fog shader independence.
|
||||||
|
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
|
||||||
|
class_name TestSpriteIntegration
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_entities = []
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers -------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _make_entity_renderer() -> Node2D:
|
||||||
|
var renderer = Node2D.new()
|
||||||
|
renderer.set_script(EntityRendererScript)
|
||||||
|
add_child(renderer)
|
||||||
|
return renderer
|
||||||
|
|
||||||
|
|
||||||
|
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
|
||||||
|
|
||||||
|
func test_entity_footprint_matches_d044_spec() -> void:
|
||||||
|
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
|
||||||
|
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
|
||||||
|
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
|
||||||
|
"D-044: ENTITY_WIDTH must be 24px"
|
||||||
|
).is_equal(24)
|
||||||
|
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
|
||||||
|
"D-044: ENTITY_HEIGHT must be 32px"
|
||||||
|
).is_equal(32)
|
||||||
|
|
||||||
|
|
||||||
|
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
|
||||||
|
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
|
||||||
|
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
|
||||||
|
# range (2 sim tiles) accurate with the tilted perspective.
|
||||||
|
var tile_2x: int = Constants.TILE_SIZE * 2
|
||||||
|
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
|
||||||
|
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
|
||||||
|
EntityRenderer.ENTITY_WIDTH, tile_2x]
|
||||||
|
).is_true()
|
||||||
|
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
|
||||||
|
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
|
||||||
|
EntityRenderer.ENTITY_HEIGHT, tile_2x]
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_entity_width_fits_within_single_tile() -> void:
|
||||||
|
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
|
||||||
|
# Ensures horizontal centering offset is positive and entity doesn't overflow.
|
||||||
|
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
|
||||||
|
"Entity width must be less than TILE_SIZE for centered layout"
|
||||||
|
).is_true()
|
||||||
|
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
|
||||||
|
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Pixel position (S16-S04) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_entity_pixel_position_at_tile_3_7() -> void:
|
||||||
|
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
|
||||||
|
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null}}]
|
||||||
|
renderer.update_entities(entity)
|
||||||
|
var node = renderer.entity_nodes[10]
|
||||||
|
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||||
|
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
|
assert_that(node.position.x).override_failure_message(
|
||||||
|
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
|
||||||
|
).is_equal_approx(expected_x, 0.1)
|
||||||
|
assert_that(node.position.y).override_failure_message(
|
||||||
|
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
|
||||||
|
).is_equal_approx(expected_y, 0.1)
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
|
||||||
|
|
||||||
|
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
|
||||||
|
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
|
||||||
|
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
|
||||||
|
# With tilted sprites, south-facing entity must visually overlap northern.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entities := [
|
||||||
|
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
]
|
||||||
|
renderer.update_entities(entities)
|
||||||
|
var north_node = renderer.entity_nodes[20]
|
||||||
|
var south_node = renderer.entity_nodes[21]
|
||||||
|
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
|
||||||
|
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
|
||||||
|
).is_true()
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_z_sort_y_position_difference_equals_tile_size() -> void:
|
||||||
|
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
|
||||||
|
# Verifies position calculation is consistent for adjacent tiles.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entities := [
|
||||||
|
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
]
|
||||||
|
renderer.update_entities(entities)
|
||||||
|
var node3 = renderer.entity_nodes[30]
|
||||||
|
var node4 = renderer.entity_nodes[31]
|
||||||
|
var delta_y := node4.position.y - node3.position.y
|
||||||
|
assert_that(delta_y).override_failure_message(
|
||||||
|
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
|
||||||
|
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_z_sort_same_y_different_x_no_y_difference() -> void:
|
||||||
|
# S16-S07: Two entities at same y but different x → same pixel.y.
|
||||||
|
# Horizontal position must not affect y-sort order.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entities := [
|
||||||
|
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
]
|
||||||
|
renderer.update_entities(entities)
|
||||||
|
var left_node = renderer.entity_nodes[40]
|
||||||
|
var right_node = renderer.entity_nodes[41]
|
||||||
|
assert_that(left_node.position.y).override_failure_message(
|
||||||
|
"Entities at same y-tile must have same pixel.y regardless of x"
|
||||||
|
).is_equal_approx(right_node.position.y, 0.1)
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
|
||||||
|
|
||||||
|
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||||
|
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
|
||||||
|
# entity_renderer.gd must be able to load these paths.
|
||||||
|
for direction in ["north", "east", "south", "west"]:
|
||||||
|
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||||
|
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||||
|
"NPC sprite missing: %s" % path
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||||
|
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
|
||||||
|
for direction in ["north", "east", "south", "west"]:
|
||||||
|
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
|
||||||
|
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||||
|
"Wall sprite missing: %s" % path
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
|
||||||
|
|
||||||
|
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
|
||||||
|
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
|
||||||
|
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
|
||||||
|
# the art-direction tilt baked into sprites.
|
||||||
|
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
|
||||||
|
"fog_shader.gd must load correctly — must not be affected by sprite changes"
|
||||||
|
).is_true()
|
||||||
|
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||||
|
"fog.gdshader must exist — fog is screen-space and sprite-independent"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
|
||||||
|
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
|
||||||
|
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
|
||||||
|
var fog = get_node_or_null("/root/FogState")
|
||||||
|
if fog == null:
|
||||||
|
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
|
||||||
|
return
|
||||||
|
# Provide visibility data but no entity renderer context
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||||
|
GameState.visibility_sectors = {
|
||||||
|
Vector2i(5, 5): "Forward",
|
||||||
|
Vector2i(6, 5): "Peripheral",
|
||||||
|
}
|
||||||
|
if fog.has_method("update_from_state"):
|
||||||
|
fog.update_from_state()
|
||||||
|
assert_that(fog.visibility_texture).override_failure_message(
|
||||||
|
"FogState visibility_texture must be populated independently of sprite state"
|
||||||
|
).is_not_null()
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.visibility_sectors.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
|
||||||
|
|
||||||
|
func test_octant_north_maps_to_north() -> void:
|
||||||
|
# S16-S12: "North" → "north"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
|
||||||
|
|
||||||
|
func test_octant_northwest_maps_to_north() -> void:
|
||||||
|
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
|
||||||
|
|
||||||
|
func test_octant_northeast_maps_to_east() -> void:
|
||||||
|
# S16-S14: "Northeast" → "east"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
|
||||||
|
|
||||||
|
func test_octant_east_maps_to_east() -> void:
|
||||||
|
# S16-S15: "East" → "east"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
|
||||||
|
|
||||||
|
func test_octant_southeast_maps_to_south() -> void:
|
||||||
|
# S16-S16: "Southeast" → "south"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
|
||||||
|
|
||||||
|
func test_octant_south_maps_to_south() -> void:
|
||||||
|
# S16-S17: "South" → "south"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
|
||||||
|
|
||||||
|
func test_octant_southwest_maps_to_west() -> void:
|
||||||
|
# S16-S18: "Southwest" → "west"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
|
||||||
|
|
||||||
|
func test_octant_west_maps_to_west() -> void:
|
||||||
|
# S16-S19: "West" → "west"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
|
||||||
|
|
||||||
|
func test_octant_unknown_string_falls_back_to_south() -> void:
|
||||||
|
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
|
||||||
|
|
||||||
|
func test_octant_empty_string_falls_back_to_south() -> void:
|
||||||
|
# S16-S21: Empty string → "south" fallback
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
|
||||||
|
|
||||||
|
func test_entity_direction_npc_always_south() -> void:
|
||||||
|
# S16-S22: NPC entity → always "south" regardless of any data field.
|
||||||
|
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
# entity_id 99 is not the player
|
||||||
|
var dir := renderer._entity_direction(99, {"entity_id": 99,
|
||||||
|
"kind": {"variant": "Npc", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"NPC entity must always return 'south'"
|
||||||
|
).is_equal("south")
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
func test_entity_direction_player_uses_player_facing() -> void:
|
||||||
|
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_facing = "North"
|
||||||
|
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||||
|
"kind": {"variant": "Player", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"Player entity with player_facing='North' must return 'north'"
|
||||||
|
).is_equal("north")
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
func test_entity_direction_player_facing_east() -> void:
|
||||||
|
# S16-S24: Player facing "East" → "east"
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_facing = "East"
|
||||||
|
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||||
|
"kind": {"variant": "Player", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"Player entity with player_facing='East' must return 'east'"
|
||||||
|
).is_equal("east")
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
|
||||||
|
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
|
||||||
|
# Diagonal octants map to one of the four cardinal sprite sets.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_facing = "Northwest"
|
||||||
|
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||||
|
"kind": {"variant": "Player", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"Player entity with player_facing='Northwest' must return 'north'"
|
||||||
|
).is_equal("north")
|
||||||
|
renderer.queue_free()
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
## Sprint 17 — Time display on insert HUD (#263)
|
||||||
|
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
|
||||||
|
##
|
||||||
|
## Spec refs:
|
||||||
|
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
||||||
|
## D-051 (diegetic insert display)
|
||||||
|
##
|
||||||
|
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
|
||||||
|
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
||||||
|
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
|
||||||
|
##
|
||||||
|
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
||||||
|
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
||||||
|
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
||||||
|
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
|
||||||
|
## reading Label.text via public node paths instead.
|
||||||
|
class_name TestTimeDisplaySprint17
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var _clock: Control = null
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.game_time = {}
|
||||||
|
var ClockScript = load("res://ui/time_display.gd")
|
||||||
|
_clock = Control.new()
|
||||||
|
_clock.set_script(ClockScript)
|
||||||
|
add_child(_clock)
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
if _clock and is_instance_valid(_clock):
|
||||||
|
_clock.queue_free()
|
||||||
|
_clock = null
|
||||||
|
GameState.game_time = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Constants.format_game_time() — pure logic, D-031
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_format_midnight() -> void:
|
||||||
|
assert_that(Constants.format_game_time(0)).is_equal("00:00")
|
||||||
|
|
||||||
|
func test_format_morning_start() -> void:
|
||||||
|
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
|
||||||
|
assert_that(Constants.format_game_time(360)).is_equal("06:00")
|
||||||
|
|
||||||
|
func test_format_noon() -> void:
|
||||||
|
assert_that(Constants.format_game_time(720)).is_equal("12:00")
|
||||||
|
|
||||||
|
func test_format_evening_start() -> void:
|
||||||
|
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
|
||||||
|
|
||||||
|
func test_format_end_of_day() -> void:
|
||||||
|
# Last valid minute — must not wrap or overflow
|
||||||
|
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
|
||||||
|
|
||||||
|
func test_format_pads_single_digit_hour() -> void:
|
||||||
|
# 30 min = 00:30
|
||||||
|
assert_that(Constants.format_game_time(30)).is_equal("00:30")
|
||||||
|
|
||||||
|
func test_format_pads_single_digit_minute() -> void:
|
||||||
|
# 121 min = 02:01
|
||||||
|
assert_that(Constants.format_game_time(121)).is_equal("02:01")
|
||||||
|
|
||||||
|
func test_format_half_past_hour() -> void:
|
||||||
|
assert_that(Constants.format_game_time(90)).is_equal("01:30")
|
||||||
|
|
||||||
|
func test_format_arbitrary_midday() -> void:
|
||||||
|
# 835 min = 13:55
|
||||||
|
assert_that(Constants.format_game_time(835)).is_equal("13:55")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# InsertClock initial state
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_insert_clock_initial_time_str_is_placeholder() -> void:
|
||||||
|
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
|
||||||
|
assert_that(_clock._time_str).is_equal("--:--")
|
||||||
|
|
||||||
|
func test_insert_clock_initial_phase_str_is_empty() -> void:
|
||||||
|
assert_that(_clock._phase_str).is_equal("")
|
||||||
|
|
||||||
|
func test_insert_clock_initial_day_str_is_empty() -> void:
|
||||||
|
assert_that(_clock._day_str).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# InsertClock.update_from_state() — reads GameState.game_time
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_update_from_state_formats_time_str() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("12:00")
|
||||||
|
|
||||||
|
func test_update_from_state_sets_phase_str() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._phase_str).is_equal("Evening")
|
||||||
|
|
||||||
|
func test_update_from_state_sets_day_str_one_indexed() -> void:
|
||||||
|
# Day 0 from server → "D1" display (1-indexed)
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._day_str).is_equal("D1")
|
||||||
|
|
||||||
|
func test_update_from_state_day_2() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._day_str).is_equal("D2")
|
||||||
|
|
||||||
|
func test_update_from_state_midnight() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("00:00")
|
||||||
|
|
||||||
|
func test_update_from_state_end_of_day() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("23:59")
|
||||||
|
|
||||||
|
func test_update_from_state_skips_empty_game_time() -> void:
|
||||||
|
# Empty game_time must not overwrite --:-- (guard in update_from_state)
|
||||||
|
GameState.game_time = {}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("--:--")
|
||||||
|
|
||||||
|
func test_update_from_state_deduplicates_same_tick() -> void:
|
||||||
|
# Calling twice with identical data must produce same result (signature cache)
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("06:00")
|
||||||
|
# Call again — result unchanged, no crash
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("06:00")
|
||||||
|
|
||||||
|
func test_update_from_state_updates_on_new_tick() -> void:
|
||||||
|
# time_of_day changes → signature changes → _time_str updates
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("01:00")
|
||||||
|
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._time_str).is_equal("02:00")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_phase_colors_has_morning() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
|
||||||
|
|
||||||
|
func test_phase_colors_has_afternoon() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
|
||||||
|
|
||||||
|
func test_phase_colors_has_evening() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
|
||||||
|
|
||||||
|
func test_phase_colors_has_night() -> void:
|
||||||
|
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
|
||||||
|
|
||||||
|
func test_phase_color_applied_after_update() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
|
||||||
|
|
||||||
|
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
|
||||||
|
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
_clock.update_from_state()
|
||||||
|
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# GameState: game_time field parsing (confirms apply_snapshot wiring)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_game_time_populated_from_snapshot() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 5, "entities": [],
|
||||||
|
"game_time": {
|
||||||
|
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
|
||||||
|
|
||||||
|
func test_game_time_all_four_phases_store_correctly() -> void:
|
||||||
|
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {
|
||||||
|
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
|
||||||
|
|
||||||
|
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
|
||||||
|
GameState.game_time = {
|
||||||
|
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
|
||||||
|
}
|
||||||
|
GameState.apply_snapshot({"tick": 2, "entities": []})
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
|
||||||
|
|
||||||
|
func test_game_time_zero_time_of_day_stored() -> void:
|
||||||
|
# time_of_day = 0 (midnight) must not be treated as falsy/missing
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# SimBridge test mode: game_time fields are valid
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_sim_bridge_snapshot_has_game_time() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
assert_that(snap.has("game_time")).is_true()
|
||||||
|
assert_that(snap.game_time is Dictionary).is_true()
|
||||||
|
|
||||||
|
func test_sim_bridge_game_time_has_required_fields() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
var gt: Dictionary = snap.game_time
|
||||||
|
assert_that(gt.has("day")).is_true()
|
||||||
|
assert_that(gt.has("time_of_day")).is_true()
|
||||||
|
assert_that(gt.has("day_phase")).is_true()
|
||||||
|
assert_that(gt.has("tick_rate")).is_true()
|
||||||
|
|
||||||
|
func test_sim_bridge_time_of_day_is_non_negative() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
|
||||||
|
|
||||||
|
func test_sim_bridge_time_of_day_within_day_bounds() -> void:
|
||||||
|
# D-031: 1440 game-minutes per day, valid range 0..1439
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
assert_that(snap.game_time.get("time_of_day", 0) as int).is_less_equal(1439)
|
||||||
|
|
||||||
|
func test_sim_bridge_day_phase_is_valid() -> void:
|
||||||
|
var snap = SimBridge._test_snapshot()
|
||||||
|
var phase: String = snap.game_time.get("day_phase", "")
|
||||||
|
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Scene: InsertClock node at InsertOverlay/TimeDisplay
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
var instance = scene.instantiate()
|
||||||
|
auto_free(instance)
|
||||||
|
add_child(instance)
|
||||||
|
|
||||||
|
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||||
|
|
||||||
|
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
var instance = scene.instantiate()
|
||||||
|
auto_free(instance)
|
||||||
|
add_child(instance)
|
||||||
|
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||||
|
})
|
||||||
|
instance._process(0.016)
|
||||||
|
|
||||||
|
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
|
||||||
|
assert_that(clock).is_not_null()
|
||||||
|
assert_that(clock._time_str).is_equal("12:00")
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Regression: debug_overlay still reads game_time correctly (#511)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_debug_overlay_reads_game_time_day_phase() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
|
||||||
|
|
||||||
|
func test_debug_overlay_reads_game_time_tick_rate() -> void:
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1, "entities": [],
|
||||||
|
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
|
||||||
|
})
|
||||||
|
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
## Sprint 15 — Basic UI framework validation tests (#74)
|
||||||
|
## Validates HUD structure, z-layer hierarchy, insert_active control,
|
||||||
|
## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07.
|
||||||
|
class_name TestUIFrameworkSprint15
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.current_tick = 0
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.current_monologue = null
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
GameState.insert_active = true
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
if _instance and is_instance_valid(_instance):
|
||||||
|
_instance.queue_free()
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-076: Layout constants
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_dialogue_max_width_set() -> void:
|
||||||
|
# DIALOGUE_MAX_WIDTH = 1200px (supersedes D-076 640px default per Tyre review).
|
||||||
|
assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(1200)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-049: Z-layer scene hierarchy
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||||
|
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
||||||
|
# Constants.CANVAS_INSERT must match.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay")
|
||||||
|
assert_that(insert_overlay).is_not_null()
|
||||||
|
assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_layer_is_canvas_layer_20() -> void:
|
||||||
|
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var ui_layer: CanvasLayer = _instance.get_node("UILayer")
|
||||||
|
assert_that(ui_layer).is_not_null()
|
||||||
|
assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI)
|
||||||
|
|
||||||
|
|
||||||
|
func test_modal_layer_is_canvas_layer_30() -> void:
|
||||||
|
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var modal_layer: CanvasLayer = _instance.get_node("ModalLayer")
|
||||||
|
assert_that(modal_layer).is_not_null()
|
||||||
|
assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL)
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_layer_above_insert_overlay() -> void:
|
||||||
|
# D-049: UILayer (20) must render above InsertOverlay (10).
|
||||||
|
assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_modal_layer_above_ui_layer() -> void:
|
||||||
|
# D-049: ModalLayer (30) must render above UILayer (20).
|
||||||
|
assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-049: Required nodes exist in correct layers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_monologue_display_exists_in_ui_layer() -> void:
|
||||||
|
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||||
|
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||||
|
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
||||||
|
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_hud_exists_in_ui_layer() -> void:
|
||||||
|
# D-049: HUD must be in UILayer.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||||
|
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||||
|
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_world_radial_exists_in_insert_overlay() -> void:
|
||||||
|
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_cursor_renderer_exists_in_ui_layer() -> void:
|
||||||
|
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# OQ-07 / D-056: insert_active controls z-layer 6 visibility
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_insert_active_defaults_true() -> void:
|
||||||
|
# OQ-07: v0.1 characters all have inserts — default true.
|
||||||
|
assert_that(GameState.insert_active).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_active_propagates_on_process() -> void:
|
||||||
|
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
||||||
|
# the next _process() call must propagate the state to z-layer-6 nodes.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
# Inject a snapshot with insert_active = false
|
||||||
|
var snap := SimBridge._test_snapshot()
|
||||||
|
snap["insert_active"] = false
|
||||||
|
GameState.apply_snapshot(snap)
|
||||||
|
assert_that(GameState.insert_active).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_active_true_from_snapshot() -> void:
|
||||||
|
# OQ-07: Snapshot with insert_active=true keeps GameState in default-on state.
|
||||||
|
var snap := SimBridge._test_snapshot()
|
||||||
|
snap["insert_active"] = true
|
||||||
|
GameState.apply_snapshot(snap)
|
||||||
|
assert_that(GameState.insert_active).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_active_missing_field_defaults_true() -> void:
|
||||||
|
# OQ-07: Old servers without insert_active field must not disable the insert.
|
||||||
|
var snap := SimBridge._test_snapshot()
|
||||||
|
snap.erase("insert_active")
|
||||||
|
GameState.apply_snapshot(snap)
|
||||||
|
assert_that(GameState.insert_active).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# #241 stub: follow_target_id for entity sprite system
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_follow_target_id_stub_exists() -> void:
|
||||||
|
# #72 / #241: follow_target_id stub must exist on GameState with default -1.
|
||||||
|
# Populated by server ticket #241 (Follow verb) when it lands.
|
||||||
|
assert_that(GameState.follow_target_id).is_equal(-1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_follow_target_id_is_negative_one_by_default() -> void:
|
||||||
|
# #72: -1 means "not following" — client #72 checks this for entity highlight.
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.apply_snapshot(SimBridge._test_snapshot())
|
||||||
|
# Server doesn't send follow_target_id yet — must stay -1 after snapshot
|
||||||
|
assert_that(GameState.follow_target_id).is_equal(-1)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Monologue display wiring (#414)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||||
|
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
||||||
|
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
# Tick 1 snapshot has a monologue (SimBridge test mode)
|
||||||
|
# _ready() consumes tick 0 (no monologue). _process() here gets tick 1.
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# If monologue_display received it, current_monologue is cleared (consume-once)
|
||||||
|
assert_that(GameState.current_monologue).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Regression: Sprint 14 integration proofs (D-030 regression markers)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fog_group_exists_in_world() -> void:
|
||||||
|
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_floor_tiles_in_fog_group() -> void:
|
||||||
|
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_entities_in_ysort_group() -> void:
|
||||||
|
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# #71: Tilemap z-filter — FloorTiles only renders floor level 0
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_tile_renderer_skips_nonzero_z() -> void:
|
||||||
|
# #71: Tiles with z != 0 must be filtered out by update_tiles().
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var tile_renderer: TileMapLayer = _instance.get_node("World/FogGroup/FloorTiles")
|
||||||
|
assert_that(tile_renderer).is_not_null()
|
||||||
|
|
||||||
|
# Feed tiles at z=0 and z=1
|
||||||
|
var tiles: Array = [
|
||||||
|
{"x": 0, "y": 0, "z": 0, "type": "floor"},
|
||||||
|
{"x": 1, "y": 0, "z": 1, "type": "floor"},
|
||||||
|
{"x": 2, "y": 0, "z": 0, "type": "wall"},
|
||||||
|
{"x": 3, "y": 0, "z": 2, "type": "door"},
|
||||||
|
]
|
||||||
|
tile_renderer.update_tiles(tiles)
|
||||||
|
|
||||||
|
# z=0 tiles should be present
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(0, 0))).is_not_equal(-1)
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(2, 0))).is_not_equal(-1)
|
||||||
|
# z=1 and z=2 tiles should NOT be present (-1 = no cell)
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1)
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1)
|
||||||