Compare commits
@@ -44,7 +44,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
- **Work in dedicated round files:** All new rounds happen in `docs/discussions/round-NN-topic.md` from the start. DISCUSSION.md is retired for new content.
|
||||
- **Update the discussion index ONLY when closing:** After a round is formally closed, update `docs/discussions/README.md` with the round entry (number, topic, decisions produced, file link).
|
||||
- **Update briefings:** After a round produces new decisions, update the relevant agent briefing files in `docs/briefings/`.
|
||||
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `db/connectors/qdrant-index <path>`.
|
||||
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `tooling/db/qdrant-index <path>`.
|
||||
|
||||
## Team workflow (mandatory)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Local Services
|
||||
|
||||
Endpoints are also preconfigured in `db/connectors/config.json`.
|
||||
Endpoints are also preconfigured in `tooling/db/config.json`.
|
||||
|
||||
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||
|
||||
@@ -17,11 +17,14 @@ docs/
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
tooling/
|
||||
db/ # Connector scripts for SQLite, Qdrant, and audio
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sprint # Sprint lifecycle CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
audio_connector.py # Stable Audio Open connector
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
|
||||
@@ -25,20 +25,22 @@
|
||||
"Bash(git ls-tree *)",
|
||||
"Bash(git rev-parse --show-toplevel)",
|
||||
|
||||
"Bash(db/connectors/ticket *)",
|
||||
"Bash(db/connectors/sprint *)",
|
||||
"Bash(db/connectors/sqlite-query *)",
|
||||
"Bash(db/connectors/sqlite-exec *)",
|
||||
"Bash(db/connectors/qdrant-search *)",
|
||||
"Bash(db/connectors/qdrant-index *)",
|
||||
"Bash(db/connectors/qdrant-health)",
|
||||
"Bash(db/connectors/qdrant-count)",
|
||||
"Bash(db/connectors/sqlite-init)",
|
||||
"Bash(db/connectors/decisions-sync)",
|
||||
"Bash(tooling/db/ticket *)",
|
||||
"Bash(tooling/db/sprint *)",
|
||||
"Bash(tooling/db/sqlite-query *)",
|
||||
"Bash(tooling/db/sqlite-exec *)",
|
||||
"Bash(tooling/db/qdrant-search *)",
|
||||
"Bash(tooling/db/qdrant-index *)",
|
||||
"Bash(tooling/db/qdrant-health)",
|
||||
"Bash(tooling/db/qdrant-count)",
|
||||
"Bash(tooling/db/sqlite-init)",
|
||||
"Bash(tooling/db/decisions-sync)",
|
||||
"Bash(tooling/db/decision *)",
|
||||
|
||||
"Bash(db/connectors/audio-generate *)",
|
||||
"Bash(db/connectors/audio-health)",
|
||||
"Bash(db/connectors/audio-post *)",
|
||||
"Bash(tooling/db/audio-generate *)",
|
||||
"Bash(tooling/db/audio-health)",
|
||||
"Bash(tooling/db/audio-post *)",
|
||||
"Bash(tooling/db/audio-batch *)",
|
||||
|
||||
"Bash(make *)",
|
||||
"Bash(make)",
|
||||
|
||||
@@ -13,7 +13,7 @@ description: >
|
||||
# Audio Generation — The Settled Reach
|
||||
|
||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
||||
wrapper scripts at `db/connectors/audio-*`.
|
||||
wrapper scripts at `tooling/db/audio-*`.
|
||||
|
||||
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||
@@ -25,21 +25,21 @@ workflow, and quality validation.
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
db/connectors/audio-health
|
||||
tooling/db/audio-health
|
||||
|
||||
# Generate a single asset (WAV only)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
tooling/db/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/output.wav
|
||||
|
||||
# Generate + post-process in one command (WAV → trim → normalize → OGG)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
tooling/db/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/gen/intermediate.wav \
|
||||
--output-ogg client/assets/audio/final.ogg
|
||||
|
||||
# Batch-generate from a manifest (preferred for multiple assets)
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
```
|
||||
|
||||
### Parameters
|
||||
@@ -138,16 +138,16 @@ AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
|
||||
|
||||
```bash
|
||||
# Full run
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
|
||||
# Dry run — preview what would be generated
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
|
||||
# Generate only specific assets
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
|
||||
# Skip assets that already have OGG files
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
```
|
||||
|
||||
### 3. Update asset docs with prompts
|
||||
@@ -190,8 +190,8 @@ For one-off generation or iteration on a specific asset:
|
||||
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||
3. Read `references/category-templates.md` for the matching template.
|
||||
4. Assemble the full prompt.
|
||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
||||
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
|
||||
5. Run `tooling/db/audio-health` to verify the API is up.
|
||||
6. Run `tooling/db/audio-generate` with `--post` or `--output-ogg` to
|
||||
generate and post-process in one step.
|
||||
7. Verify the output (file size, duration).
|
||||
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
|
||||
@@ -218,12 +218,12 @@ If you need to post-process separately (e.g., re-normalizing an existing file):
|
||||
|
||||
```bash
|
||||
# Full pipeline: trim → normalize → convert
|
||||
db/connectors/audio-post pipeline input.wav --output output.ogg
|
||||
tooling/db/audio-post pipeline input.wav --output output.ogg
|
||||
|
||||
# Individual steps
|
||||
db/connectors/audio-post trim input.wav
|
||||
db/connectors/audio-post normalize input.wav --lufs -16
|
||||
db/connectors/audio-post convert input.wav --output output.ogg
|
||||
tooling/db/audio-post trim input.wav
|
||||
tooling/db/audio-post normalize input.wav --lufs -16
|
||||
tooling/db/audio-post convert input.wav --output output.ogg
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
@@ -169,7 +169,7 @@ 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}"
|
||||
tooling/db/ticket create bug "{title}" --team {team} --description "{description}"
|
||||
```
|
||||
|
||||
The description should include:
|
||||
|
||||
@@ -20,14 +20,14 @@ and workflows.
|
||||
|
||||
For precise indexing of specific content:
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
|
||||
python3 tooling/db/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
|
||||
```
|
||||
|
||||
### Create collection
|
||||
|
||||
Initialize the Qdrant collection (run once during setup):
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py create-collection
|
||||
python3 tooling/db/qdrant_connector.py create-collection
|
||||
```
|
||||
|
||||
## Bulk Indexing
|
||||
@@ -35,7 +35,7 @@ python3 db/connectors/qdrant_connector.py create-collection
|
||||
Index all project documents at once:
|
||||
```bash
|
||||
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
|
||||
db/connectors/qdrant-index "$f"
|
||||
tooling/db/qdrant-index "$f"
|
||||
done
|
||||
```
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ Extract ticket IDs from `#NNN` patterns. For each ticket that is
|
||||
currently `in_progress`, update it to `review`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> review
|
||||
tooling/db/ticket status <id> review
|
||||
```
|
||||
|
||||
Report which tickets were moved to review. Skip tickets that are
|
||||
|
||||
@@ -74,44 +74,44 @@ If the diff is empty, report "No changes to review" and stop.
|
||||
|
||||
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||
2. Read source files directly from the branch worktree (see below).
|
||||
2. Read source files directly from the team directory (see below).
|
||||
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews.
|
||||
|
||||
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
||||
worktrees. Each team branch is checked out at:
|
||||
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
|
||||
is checked out in its own directory at:
|
||||
|
||||
```
|
||||
/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||
/var/mnt/data/projects/settled-reach/<branch>/
|
||||
```
|
||||
|
||||
For example, the `copy` branch lives at:
|
||||
For example, the `copy` team directory is at:
|
||||
```
|
||||
/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/...
|
||||
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
|
||||
```
|
||||
|
||||
**All reviewer agents** (regardless of Bash access) should read source files
|
||||
from the worktree path using the Read tool. This is more reliable than
|
||||
from the team directory using the Read tool. This is more reliable than
|
||||
`git show origin/<branch>:<path>` because:
|
||||
- All agents have Read access (no Bash dependency)
|
||||
- Files are always the actual branch checkout (no stale cache)
|
||||
- No risk of accidentally reading from main's working directory
|
||||
|
||||
When constructing reviewer prompts, tell agents to read files from the
|
||||
worktree path. Example instruction for agents:
|
||||
team directory. Example instruction for agents:
|
||||
|
||||
```
|
||||
Read the changed files from the branch worktree. The branch is checked
|
||||
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||
Read the changed files from the team directory. The branch is checked
|
||||
out at: /var/mnt/data/projects/settled-reach/<branch>/
|
||||
|
||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
use: /var/mnt/data/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).
|
||||
directory (they're identical to main, but using the team directory path
|
||||
keeps agents grounded in the correct location).
|
||||
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**All agents read from worktree paths.** Each branch is checked out at:
|
||||
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
|
||||
**All reviewer agents read from team directories.** Each team branch is
|
||||
checked out in its own directory at:
|
||||
`/var/mnt/data/projects/settled-reach/<branch>/`
|
||||
|
||||
Tell every reviewer agent to read source files from the worktree using the
|
||||
Read tool. Include the worktree base path and a list of changed files in
|
||||
every prompt. Do NOT rely on `git show` or paste file contents — agents
|
||||
can read directly from the worktree.
|
||||
Tell every reviewer agent to read source files from the team directory
|
||||
using the Read tool. Include the directory path and a list of changed
|
||||
files in every prompt. Do NOT rely on `git show` or paste file contents —
|
||||
agents can read directly from the directory.
|
||||
|
||||
Note: cross-directory reading is only permitted for review agents spawned
|
||||
from the `main` team. Team agents must stay within their own directory.
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
**Hoshe (Code Quality)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the team directory, then review for:
|
||||
- Correctness and bug risks
|
||||
- Error handling gaps
|
||||
- Test coverage (are new features tested?)
|
||||
@@ -25,8 +29,8 @@ can read directly from the worktree.
|
||||
|
||||
**Tyre (Architecture)**
|
||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the team directory
|
||||
first, then review for:
|
||||
- Architectural consistency with project decisions
|
||||
- API/interface design quality
|
||||
@@ -38,8 +42,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- Formatting consistency (markdown, file naming, frontmatter)
|
||||
- Broken references or links
|
||||
- Spelling and grammar
|
||||
@@ -48,9 +52,9 @@ can read directly from the worktree.
|
||||
|
||||
**Paula (Narrative Depth)**
|
||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
- Prompt: Provide the team directory path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Paula to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
files from the team directory using the Read tool, then review for:
|
||||
- Narrative quality and character voice consistency
|
||||
- Whether dialogue and monologue feel authentic to the characters
|
||||
- Consequences and stakes — do choices carry weight?
|
||||
@@ -59,9 +63,9 @@ can read directly from the worktree.
|
||||
|
||||
**Miri (World Consistency)**
|
||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
- Prompt: Provide the team directory path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Miri to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
files from the team directory using the Read tool, then review for:
|
||||
- Lore accuracy — do facts match established setting?
|
||||
- Internal consistency across files
|
||||
- IP originality — nothing should read as a copy from another franchise
|
||||
@@ -72,8 +76,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- File format and naming conventions
|
||||
- Asset organization and directory structure
|
||||
- Missing or broken references in scene/resource files
|
||||
@@ -81,7 +85,7 @@ can read directly from the worktree.
|
||||
|
||||
**Araminta (Art Direction)**
|
||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Araminta to read the style guide and relevant design docs from the
|
||||
worktree first, then review for:
|
||||
- Visual consistency with the established style guide
|
||||
@@ -94,8 +98,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- File format and naming conventions
|
||||
- Audio asset organization and directory structure
|
||||
- Missing or broken references
|
||||
@@ -103,8 +107,8 @@ can read directly from the worktree.
|
||||
|
||||
**Ozzie (Player Experience)**
|
||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the worktree using the Read tool, then
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the team directory using the Read tool, then
|
||||
review for:
|
||||
- Emotional impact — does the audio enhance the moment?
|
||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||
|
||||
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
|
||||
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
|
||||
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
|
||||
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
|
||||
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
|
||||
|
||||
When writing briefings, name the assigned agents in the **Agents** line of each
|
||||
file so the team knows who to spawn.
|
||||
|
||||
### Planning Team Tickets
|
||||
|
||||
Some tickets need **design discussion** before implementation can begin — tagged
|
||||
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
|
||||
questions. These run on the `planning` branch as structured discussions with
|
||||
the user and a purpose-assembled agent panel.
|
||||
|
||||
**When to create a planning ticket:**
|
||||
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
|
||||
- Ticket blocks 2+ downstream tickets across different teams
|
||||
- Open Q-NNN items that block sprint candidates
|
||||
- Architectural decisions that need multi-domain input before implementation
|
||||
|
||||
**Planning briefing format** (differs from implementation briefings):
|
||||
- **Agents line**: List agents by domain relevance, not fixed team roster.
|
||||
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
|
||||
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
|
||||
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
|
||||
records decisions, updates domain files) and SI (project manager — creates
|
||||
follow-up tickets, updates sprint assignments).
|
||||
- **Discussion rounds**: Structure the conversation into 2-3 rounds
|
||||
(inventory → proposals → convergence)
|
||||
- **Context section**: List all existing design docs, decisions, and related
|
||||
tickets that participants must read before the discussion
|
||||
- **Output specification**: What the discussion must produce — typically a
|
||||
D-record in `decisions/`, possibly a design doc in `docs/design/`
|
||||
- **Decision questions**: Specific questions the discussion must answer,
|
||||
not open-ended exploration
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Run sprint prepare
|
||||
@@ -58,7 +88,7 @@ file so the team knows who to spawn.
|
||||
Get carry-overs, backlog candidates, and decision gaps in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint prepare
|
||||
tooling/db/sprint prepare
|
||||
```
|
||||
|
||||
This auto-detects the next sprint number (max ID + 1), creates the sprint
|
||||
@@ -73,10 +103,10 @@ record in `planning` status if needed, and outputs:
|
||||
For critical epics, check their children for granular candidates:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket children <epic_id>
|
||||
tooling/db/ticket children <epic_id>
|
||||
```
|
||||
|
||||
Use `db/connectors/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
Use `tooling/db/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
|
||||
### 3. Read existing code state
|
||||
|
||||
@@ -124,13 +154,12 @@ Create `docs/sprints/sprint-N/` and write one file per team.
|
||||
Read the template at `references/briefing-template.md` in this skill directory
|
||||
for the exact file structure.
|
||||
|
||||
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
|
||||
Each team branch is checked out in its own worktree, and each worktree
|
||||
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the worktree/git root. Example:
|
||||
**IMPORTANT — relative paths only:** Each team works in its own directory
|
||||
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the working directory. Example:
|
||||
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
|
||||
paths that navigate outside the git root (`../sibling-worktree/...`).
|
||||
Agents must stay within the git root they are running in.
|
||||
paths that navigate outside (`../sibling-dir/...`).
|
||||
Agents must stay within their team's working directory.
|
||||
|
||||
Key requirements per file:
|
||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||
@@ -154,16 +183,29 @@ Update it with the theme and goal, then assign tickets:
|
||||
|
||||
```bash
|
||||
# Update the sprint with theme and goal
|
||||
db/connectors/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
|
||||
tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
|
||||
|
||||
# Assign tickets
|
||||
db/connectors/ticket sprint assign <ticket_id> <sprint_id>
|
||||
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
|
||||
```
|
||||
|
||||
The sprint stays in `planning` status until explicitly activated via
|
||||
`db/connectors/sprint start`. This prevents starting an unplanned sprint.
|
||||
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
|
||||
|
||||
### 8. Present summary
|
||||
### 8. Commit and push
|
||||
|
||||
Stage the briefing files and any other changes (db backup, closed tickets),
|
||||
then commit and push so worktree branches can pull the planning artifacts:
|
||||
|
||||
```bash
|
||||
git add docs/sprints/sprint-N/
|
||||
make db-backup
|
||||
git add docs/backups/settledreach.db.backup
|
||||
git commit -m "chore(meta): plan Sprint N: Theme"
|
||||
git push
|
||||
```
|
||||
|
||||
### 9. Present summary
|
||||
|
||||
Output:
|
||||
- Sprint number, theme, and goal
|
||||
@@ -171,3 +213,4 @@ Output:
|
||||
- Carry-over count
|
||||
- Open questions that need early resolution
|
||||
- Files written
|
||||
- Commit pushed to main
|
||||
|
||||
@@ -26,7 +26,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
|
||||
|---|-------|------------|
|
||||
| #ID | Title | #dependency or — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ When `/sprint-start` is run on `main`, assess the current sprint state
|
||||
and do the next right thing. Query the database to determine the state:
|
||||
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
```
|
||||
|
||||
Then follow the **first matching case**:
|
||||
@@ -48,7 +48,7 @@ Then follow the **first matching case**:
|
||||
First, check whether the sprint's work is actually done:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint status
|
||||
tooling/db/sprint status
|
||||
```
|
||||
|
||||
This shows ticket counts by status (done, in_progress, backlog).
|
||||
@@ -81,7 +81,7 @@ explicitly chooses to close.
|
||||
#### A1. Close the active sprint
|
||||
|
||||
```bash
|
||||
db/connectors/sprint stop
|
||||
tooling/db/sprint stop
|
||||
```
|
||||
|
||||
This marks the active sprint as completed and lists carry-over candidates.
|
||||
@@ -142,7 +142,7 @@ A sprint is ready to activate. Verify it looks complete:
|
||||
```
|
||||
2. Check the ticket count:
|
||||
```bash
|
||||
db/connectors/sprint status --sprint N
|
||||
tooling/db/sprint status --sprint N
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
@@ -151,7 +151,7 @@ and suggest running `/sprint-plan` to complete planning.
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start
|
||||
tooling/db/sprint start
|
||||
```
|
||||
|
||||
Then report:
|
||||
@@ -184,7 +184,7 @@ If the merge has conflicts, report them and stop — do not force-resolve.
|
||||
Run the sprint CLI to get the full context dump in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start-work
|
||||
tooling/db/sprint start-work
|
||||
```
|
||||
|
||||
This auto-detects the active sprint and current team from the branch.
|
||||
@@ -204,7 +204,7 @@ If no matching briefing exists for the team, suggest running
|
||||
|
||||
For tickets that need more detail than the `start-work` summary provides:
|
||||
```bash
|
||||
db/connectors/ticket show <id>
|
||||
tooling/db/ticket show <id>
|
||||
```
|
||||
|
||||
### 6. Read key decisions
|
||||
@@ -218,7 +218,7 @@ Mark all actionable (unblocked, non-done) tickets in the sprint as
|
||||
`in_progress`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> in_progress
|
||||
tooling/db/ticket status <id> in_progress
|
||||
```
|
||||
|
||||
Then output a summary:
|
||||
@@ -290,14 +290,19 @@ Task(
|
||||
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
|
||||
within the current working directory. Do NOT navigate to
|
||||
parent or sibling directories. Do NOT follow .git pointers
|
||||
to other directories.
|
||||
|
||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead. No exceptions.
|
||||
|
||||
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
tooling/db/ticket show 528
|
||||
tooling/db/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
@@ -346,7 +351,7 @@ Task(
|
||||
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||
on your own.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||
Use `tooling/db/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
run_in_background: true
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ the workflow.
|
||||
Run these two commands in parallel:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint sweep
|
||||
tooling/db/sprint sweep
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,60 +17,60 @@ section. This skill covers the full command reference.
|
||||
|
||||
### List tickets (full flags)
|
||||
```bash
|
||||
db/connectors/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
```
|
||||
|
||||
### Create ticket
|
||||
```bash
|
||||
db/connectors/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
|
||||
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
|
||||
```
|
||||
Types: `initiative`, `epic`, `story`, `task`, `bug`
|
||||
Priorities: `critical`, `high`, `medium`, `low`
|
||||
|
||||
### Update status
|
||||
```bash
|
||||
db/connectors/ticket status <id> <new_status>
|
||||
db/connectors/ticket done <id> [<id> ...]
|
||||
tooling/db/ticket status <id> <new_status>
|
||||
tooling/db/ticket done <id> [<id> ...]
|
||||
```
|
||||
Statuses: `backlog`, `ready`, `in_progress`, `review`, `done`, `cancelled`
|
||||
|
||||
### Assignment
|
||||
```bash
|
||||
db/connectors/ticket assign <id> <agent>
|
||||
db/connectors/ticket unassign <id>
|
||||
tooling/db/ticket assign <id> <agent>
|
||||
tooling/db/ticket unassign <id>
|
||||
```
|
||||
|
||||
### Team assignment
|
||||
```bash
|
||||
db/connectors/ticket team <id> <teams>
|
||||
tooling/db/ticket team <id> <teams>
|
||||
```
|
||||
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
|
||||
|
||||
### Sprint management
|
||||
```bash
|
||||
db/connectors/ticket sprint [--active]
|
||||
db/connectors/ticket sprint assign <id> <sprint_id>
|
||||
tooling/db/ticket sprint [--active]
|
||||
tooling/db/ticket sprint assign <id> <sprint_id>
|
||||
```
|
||||
|
||||
For sprint-scoped operations (status overview, context dumps, lifecycle),
|
||||
use the dedicated sprint CLI instead: `db/connectors/sprint --help`
|
||||
use the dedicated sprint CLI instead: `tooling/db/sprint --help`
|
||||
|
||||
### Dependencies
|
||||
```bash
|
||||
db/connectors/ticket deps <id>
|
||||
tooling/db/ticket deps <id>
|
||||
```
|
||||
|
||||
### Search and browse
|
||||
```bash
|
||||
db/connectors/ticket search <keyword>
|
||||
db/connectors/ticket epics [--status S]
|
||||
db/connectors/ticket children <id>
|
||||
db/connectors/ticket count [--status S]
|
||||
tooling/db/ticket search <keyword>
|
||||
tooling/db/ticket epics [--status S]
|
||||
tooling/db/ticket children <id>
|
||||
tooling/db/ticket count [--status S]
|
||||
```
|
||||
|
||||
### Batch show
|
||||
```bash
|
||||
db/connectors/ticket show --brief <id> [<id>...]
|
||||
tooling/db/ticket show --brief <id> [<id>...]
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -6,9 +6,107 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.23] — 2026-03-04
|
||||
|
||||
### Added
|
||||
- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with per-tile type data alongside walkability (#576, D-094)
|
||||
- Location YAML tile format — hand-author tiles as string arrays (F/W/V/R characters), loaded into WalkabilityMap on production startup (#577)
|
||||
- Chunk streaming system — ChunkLoadRadius and cadence-gated load/unload around player position, v0.1 covers full district (#578, D-012)
|
||||
- EngagementRecord component — per-NPC observation time, conversation count, and monologue trigger count tracked by perception/dialogue/monologue systems (#570)
|
||||
- MovementHistoryBuffer resource — 3000-tick ring buffer of player positions with co-presence proximity query (#571)
|
||||
- Storyteller lifecycle rules — single activation per session, no concurrency, terminal resolution constants (#572)
|
||||
- Storyteller activation_pass() — gate/proximity/engagement scoring/routing/module selection/TriangleActivatedEvent on 10-tick cadence (#579)
|
||||
- Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580)
|
||||
- Debug console client — tilde-toggle UI panel with command input, output log, settings toggle, and full DebugCommandKind dispatch via protocol v18 (#581)
|
||||
- Entity-bound dialogue speaker colors — NPC colors assigned by entity ID (not screen position) with per-conversation lifecycle and round-robin palette (#573)
|
||||
- Sova Transit District tile maps — 5 locations authored: The Terminal (44×28), The Last Shift (34×22), Maintenance Corridors (58×6), Gate Ground (40×34), Gate Gallery (32×10) (#582, #583)
|
||||
|
||||
### Fixed
|
||||
- LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584)
|
||||
- LOS boundary walls client — BoundaryWall tiles render through fog without marking explored, 4 new fog tests verify lifecycle (#585)
|
||||
- Entity renderer test failures — updated 7 stale ColorRect/position assertions for Sprite2D migration, fixed SoundIndicatorRenderer class cache (#574)
|
||||
- Dialogue speaker color contrast — re-enforce contrast floor after desaturation for passive (overheard) lines
|
||||
- PROTOCOL_VERSION 17 → 18 mismatch — client rejected every server snapshot
|
||||
- Debug console D-088 pause — sim now pauses while console is open, matching dialogue/settings overlay behavior
|
||||
- Debug console settings toggle reads live state instead of ConfigFile, preventing checkbox divergence
|
||||
|
||||
### Changed
|
||||
- PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant)
|
||||
|
||||
## [v0.1.22] — 2026-03-03
|
||||
|
||||
### Added
|
||||
- Visual test harness — `make screenshot`, `make test-visual`, `make visual-update` for automated visual regression testing with golden PNGs across 11 scenarios (fog, HUD, dialogue, minimap)
|
||||
- Visual movie mode — `make visual-movie` captures interaction flows as frame sequences with contact sheet generation
|
||||
- World seed protocol — StartupMessage carries world_seed from client to server after handshake, enabling deterministic NPC population seeding (D-010, D-029)
|
||||
- EntanglementConfig — per-seed NPC population ratios (flat/mundane/intrigue) sampled from seeded RNG with D-029 bounds, ensuring same seed = same world (#175, #178)
|
||||
- Fog debug mode — toggle FogState.debug_exploration to render raw exploration texture as colored overlay for diagnostic use
|
||||
- D-110 through D-112: z-level addressing, subterranean architecture, no instancing decisions
|
||||
- Q-051: speech bubble indicator over speaking NPCs
|
||||
- Sprint 22 "Wire" briefings (server, client, visual, CI, planning, joint)
|
||||
|
||||
### Added (server)
|
||||
- Production NPC pool generation — 23 authored Sova NPCs spawn with EntanglementTag (Flat/Intrigue) based on triangle membership (#176, D-029)
|
||||
- Authored triangle instantiation — 5 Sova triangles (3 active forks, 2 passive tensions) loaded from content YAML with deterministic IDs (#188, D-087)
|
||||
- Contamination activation mechanic — timer-based storyteller fires after 30 game-minutes, pressures active triangles, emits ContaminationEvent (#254)
|
||||
- Modifications data model stub — Vec<Modification> on chunk entities, round-trips through save/load for future construction DLC (#567, D-112)
|
||||
- Zone Gate gauntlet room — two-zone test room with door boundary, zone crossing detection system (#512)
|
||||
- Fuzzy map tests — 50-seed randomized testing of procedural maps against 4 structural invariants (#509)
|
||||
|
||||
### Fixed
|
||||
- Fog shader: silent compilation failure in OpenGL3 compat mode — removed `return` statements from fragment() which are not supported, causing fog overlay to render as no-op (root cause of Sprint 22 fog regression)
|
||||
- Fog system: blocky stair-stepped edges at vision cone boundary — doubled Gaussian blur step size for D-066 compliant 6-8 tile smooth gradient (#569)
|
||||
- Fog system: zero visibility in explored areas — switched bounds calculation from visible_tiles (empty in live server mode) to visible_positions, and removed shader guard that cut off gradient bleed into unexplored tiles (#569)
|
||||
- Fog shader alpha tuned to D-059 spec: light fog 0.25-0.35 (was 0.25-0.55), deep fog 0.55-0.70 (was 0.78-0.90) — world content now visible through fog instead of hidden behind it (#563)
|
||||
|
||||
### Changed
|
||||
- Fog shader now distinguishes light fog (near cone, neutral dark) from deep fog (far from cone, zone temperature tint) with separate Perlin noise breathing cycles (8-10s / 15-20s)
|
||||
- Zone temperature tint populated per-tile from server zone_id: bar=warm amber-dark, hub=cool blue-dark, corridor=neutral dark (D-059/D-046/D-077)
|
||||
- Simplified vision cone from 3-sector (forward/peripheral/blind) to forward-only 120° arc — server sends only forward-cone tiles, client renders explored tiles behind the player with light fog overlay
|
||||
- Simplified fog shader from 5-layer to 3-layer model (clear, explored, unexplored)
|
||||
- Fog texture resize now preserves exploration data — tiles behind the player stay as light fog instead of reverting to unexplored black
|
||||
- Updated D-015/D-017 perception decisions to reflect simplified cone model
|
||||
- Moved connector scripts from db/connectors/ to tooling/db/ (#274) — backwards-compat symlink removed in #568
|
||||
|
||||
### Removed
|
||||
- db/connectors symlink — all references now use tooling/db/ directly (#568)
|
||||
|
||||
## [v0.1.20] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
|
||||
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
|
||||
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
|
||||
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
|
||||
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
|
||||
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
|
||||
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
|
||||
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
|
||||
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
|
||||
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
|
||||
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
|
||||
|
||||
### Fixed
|
||||
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
|
||||
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
|
||||
- Validate dangling with_role references in TriangleDef constraint validation
|
||||
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
|
||||
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
|
||||
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
|
||||
- Consolidate near-identical unit tests with integration counterparts
|
||||
|
||||
### Changed
|
||||
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
|
||||
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
|
||||
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
|
||||
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
|
||||
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
|
||||
|
||||
## [v0.1.19] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
|
||||
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
|
||||
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
|
||||
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
|
||||
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
|
||||
@@ -96,6 +194,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||
|
||||
### Changed
|
||||
- Team boundary framing — replaced worktree-centric language with `$WORKTREE_TEAM` env var identity across CLAUDE.md and skills (sprint-start, sprint-plan, pr-review) to prevent agents from following `.git` pointers across boundaries
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||
|
||||
@@ -14,7 +14,7 @@ server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
docs/ # Architecture, design, briefings, sprints, workshops
|
||||
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
|
||||
db/ # Schema + seed data (connectors moved to tooling/db/)
|
||||
.claude/ # Agents, skills, rules
|
||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||
```
|
||||
@@ -27,14 +27,15 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
### Worktree boundaries
|
||||
### Team boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
|
||||
|
||||
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- **Exception — stale git lock files:** Worktree index locks live in the shared `.git` directory (e.g. `main/.git/worktrees/copy/index.lock`). If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own worktree only**. Never touch lock files belonging to other worktrees.
|
||||
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
|
||||
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
|
||||
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
|
||||
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
|
||||
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -42,7 +43,7 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
|
||||
### Before starting work
|
||||
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
||||
2. Use `db/connectors/ticket show <id>` for full ticket details
|
||||
2. Use `tooling/db/ticket show <id>` for full ticket details
|
||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||
|
||||
@@ -52,19 +53,26 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
|
||||
| Tool | Command | Full reference |
|
||||
|------|---------|----------------|
|
||||
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||
| 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 ..."` | — |
|
||||
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### Testing preferences
|
||||
|
||||
- **Prefer live Gauntlet testing over mocks.** For visual tests and rendering verification, use the full client/server pipeline (`--test-mode` + `SR_LIVE=1`) instead of TestHarness mocks. The Gauntlet test world produces production-identical data. Mocks can mask rendering bugs by taking different code paths.
|
||||
- **Gauntlet rooms are immutable.** Never modify existing rooms — new systems get new rooms. This ensures StableId determinism and fixture stability.
|
||||
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
|
||||
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
- **Claim IDs before writing:** `db/connectors/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- 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
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||
- Tickets: managed via `tooling/db/ticket` CLI or `/ticket` skill
|
||||
|
||||
@@ -8,7 +8,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
|
||||
screenshot visual-movie test-visual visual-update
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -53,6 +54,11 @@ help:
|
||||
@echo " make checklist-generate Validate checklists + print condition summary"
|
||||
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
||||
@echo ""
|
||||
@echo " make screenshot Ad-hoc visual capture (SCENARIO=name, default: fog_3state)"
|
||||
@echo " make visual-movie Flow capture with contact sheet (FLOW=name)"
|
||||
@echo " make test-visual Run visual golden regression tests"
|
||||
@echo " make visual-update Regenerate visual goldens and stage for commit"
|
||||
@echo ""
|
||||
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
|
||||
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
||||
@@ -284,16 +290,16 @@ db-install:
|
||||
# --- Decisions ---
|
||||
|
||||
decisions-sync:
|
||||
@db/connectors/decisions-sync
|
||||
@tooling/db/decisions-sync
|
||||
|
||||
decisions-coverage:
|
||||
@db/connectors/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain"
|
||||
@tooling/db/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain"
|
||||
|
||||
decisions-active:
|
||||
@db/connectors/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
|
||||
@tooling/db/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
|
||||
|
||||
decisions-orphan:
|
||||
@db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
|
||||
@tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
|
||||
|
||||
# --- Content Validation ---
|
||||
|
||||
@@ -318,6 +324,20 @@ debug-schedule:
|
||||
@echo "Dumping bevy_ecs schedule graph..."
|
||||
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
|
||||
|
||||
# --- Visual test harness ---
|
||||
|
||||
screenshot:
|
||||
@tests/run-visual --screenshot $(SCENARIO)
|
||||
|
||||
visual-movie:
|
||||
@tests/run-visual --movie $(FLOW)
|
||||
|
||||
test-visual:
|
||||
@tests/run-visual
|
||||
|
||||
visual-update:
|
||||
@tests/run-visual --update
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://850hx6cd5kx7"
|
||||
path="res://.godot/imported/npc_generic_east_64.png-bf22e76d70922112e99bf747d85a0a04.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_east_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_east_64.png-bf22e76d70922112e99bf747d85a0a04.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bx6s0yglmpt2l"
|
||||
path="res://.godot/imported/npc_generic_north_64.png-296e233a60c8b9efed025a82a69614df.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_north_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_north_64.png-296e233a60c8b9efed025a82a69614df.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dpsuq148mksls"
|
||||
path="res://.godot/imported/npc_generic_south_64.png-c121e02d806f6dcc3ed440484827c258.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_south_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_south_64.png-c121e02d806f6dcc3ed440484827c258.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dgt84hobbdumx"
|
||||
path="res://.godot/imported/npc_generic_west_64.png-9ee237384537f7357d37802b6cfae559.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_west_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_west_64.png-9ee237384537f7357d37802b6cfae559.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bcmer1bsugk8b"
|
||||
path="res://.godot/imported/wall_structural_east_64.png-f868b267dcc1824e2b0fe213e49b4996.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_east_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_east_64.png-f868b267dcc1824e2b0fe213e49b4996.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dd5j0q6cu674m"
|
||||
path="res://.godot/imported/wall_structural_north_64.png-f7eceb6d561e7e07b3bac2e28c659628.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_north_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_north_64.png-f7eceb6d561e7e07b3bac2e28c659628.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cctlhvaolom3x"
|
||||
path="res://.godot/imported/wall_structural_south_64.png-c34925f9a5a3f6b5968e73e51953367e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_south_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_south_64.png-c34925f9a5a3f6b5968e73e51953367e.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
|
||||
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ughjt5s8la2p"
|
||||
path="res://.godot/imported/wall_structural_west_64.png-7c39e0c692d32348190875c4ad99b227.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_west_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_west_64.png-7c39e0c692d32348190875c4ad99b227.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
|
||||
@@ -109,6 +109,7 @@ notifications:
|
||||
load_failed: "Load failed."
|
||||
connection_lost: "Signal interrupted."
|
||||
connection_restored: "Signal restored."
|
||||
loading: "Resuming..."
|
||||
|
||||
# ============================================================
|
||||
# KNOWLEDGE PANEL LABELS
|
||||
@@ -183,6 +184,9 @@ menu:
|
||||
confirm_quit: "Unsaved progress will be lost."
|
||||
confirm_yes: "Yes"
|
||||
confirm_no: "No"
|
||||
load_game_browse: "LOAD GAME"
|
||||
load_game_back: "BACK"
|
||||
load_game_empty: "No saves found."
|
||||
|
||||
settings:
|
||||
audio_volume: "Volume"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=26 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=28 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -21,10 +21,12 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[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="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="Script" path="res://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"]
|
||||
[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"]
|
||||
[ext_resource type="PackedScene" uid="uid://b2ndm9rvx8cqp" path="res://ui/debug_console.tscn" id="27_debug_console"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -190,3 +192,9 @@ layer = 30
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
||||
|
||||
; #257: Loading screen — full-screen overlay during save/load round-trip
|
||||
[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")]
|
||||
|
||||
; #581: Debug console — tilde key toggles, bottom 40% of screen
|
||||
[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")]
|
||||
|
||||
@@ -59,8 +59,66 @@ text = "CONTINUE"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="LoadGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "LOAD GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "QUIT"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
|
||||
[node name="LoadGamePanel" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
visible = false
|
||||
|
||||
[node name="PanelBg" type="ColorRect" parent="LoadGamePanel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 0.96)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="LoadGamePanel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -160.0
|
||||
offset_top = -180.0
|
||||
offset_right = 160.0
|
||||
offset_bottom = 180.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="LoadGamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
text = "LOAD GAME"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 20
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="SavesScroll" type="ScrollContainer" parent="LoadGamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(320, 240)
|
||||
|
||||
[node name="SavesList" type="VBoxContainer" parent="LoadGamePanel/VBox/SavesScroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="BackBtn" type="Button" parent="LoadGamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
text = "BACK"
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
|
||||
@@ -8,13 +8,29 @@ extends Node
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
|
||||
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
|
||||
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
|
||||
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
|
||||
# Low saturation is intentional (D-046): tints are subtle — distinguishable as warm/cool/neutral
|
||||
# in side-by-side comparison, not garish. The "Hopper test" validates this.
|
||||
# Colors are dark tints used as the fog overlay in the deep fog zone:
|
||||
# hub/workplace: #1a1f2e (cool blue-dark — terminal, institutional)
|
||||
# bar: #2a1f15 (warm amber-dark — social, inhabited)
|
||||
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
|
||||
const ZONE_TINTS: Dictionary = {
|
||||
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
|
||||
"workplace": Color(0.102, 0.122, 0.180), # same as hub
|
||||
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
|
||||
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
|
||||
}
|
||||
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
|
||||
|
||||
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
|
||||
var visibility_texture: ImageTexture
|
||||
var exploration_texture: ImageTexture
|
||||
@@ -25,16 +41,34 @@ var _exp_bytes: PackedByteArray
|
||||
var _vis_image: Image
|
||||
var _exp_image: Image
|
||||
var _tint_image: Image
|
||||
# Zone tint stored as 3-channel RGB bytes (R, G, B per pixel) for preservation across resizes
|
||||
var _tint_bytes: PackedByteArray
|
||||
var _width: int = 1
|
||||
var _height: int = 1
|
||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
||||
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
|
||||
|
||||
func _resize(bounds: Rect2i) -> void:
|
||||
var old_bounds := map_bounds
|
||||
var old_exp := _exp_bytes
|
||||
var old_tint := _tint_bytes # empty on first call (_ready); guard at line 104 skips copy
|
||||
var old_w := _width
|
||||
var old_h := _height
|
||||
|
||||
map_bounds = bounds
|
||||
_width = maxi(bounds.size.x, 1)
|
||||
_height = maxi(bounds.size.y, 1)
|
||||
@@ -49,41 +83,92 @@ func _resize(bounds: Rect2i) -> void:
|
||||
_exp_bytes = PackedByteArray()
|
||||
_exp_bytes.resize(sz)
|
||||
_exp_bytes.fill(EXP_UNEXPLORED)
|
||||
# Preserve exploration data from old bounds into new bounds
|
||||
if old_exp.size() > 0 and old_w > 0 and old_h > 0:
|
||||
var dx: int = old_bounds.position.x - bounds.position.x
|
||||
var dy: int = old_bounds.position.y - bounds.position.y
|
||||
for oy in range(old_h):
|
||||
var ny: int = oy + dy
|
||||
if ny < 0 or ny >= _height:
|
||||
continue
|
||||
for ox in range(old_w):
|
||||
var nx: int = ox + dx
|
||||
if nx < 0 or nx >= _width:
|
||||
continue
|
||||
var old_val: int = old_exp[oy * old_w + ox]
|
||||
if old_val > EXP_UNEXPLORED:
|
||||
_exp_bytes[ny * _width + nx] = old_val
|
||||
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||
exploration_texture = ImageTexture.create_from_image(_exp_image)
|
||||
|
||||
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
|
||||
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
|
||||
_tint_image.fill(Color(0.05, 0.05, 0.08))
|
||||
# Zone tint — 3 bytes per pixel (RGB), default neutral dark
|
||||
var tint_sz := sz * 3
|
||||
_tint_bytes = PackedByteArray()
|
||||
_tint_bytes.resize(tint_sz)
|
||||
var default_r := int(ZONE_TINT_DEFAULT.r * 255.0)
|
||||
var default_g := int(ZONE_TINT_DEFAULT.g * 255.0)
|
||||
var default_b := int(ZONE_TINT_DEFAULT.b * 255.0)
|
||||
for i in range(sz):
|
||||
_tint_bytes[i * 3 + 0] = default_r
|
||||
_tint_bytes[i * 3 + 1] = default_g
|
||||
_tint_bytes[i * 3 + 2] = default_b
|
||||
# Preserve zone tint data from old bounds (zone tints are stable — tile zone never changes)
|
||||
if old_tint.size() > 0 and old_w > 0 and old_h > 0:
|
||||
var dx: int = old_bounds.position.x - bounds.position.x
|
||||
var dy: int = old_bounds.position.y - bounds.position.y
|
||||
for oy in range(old_h):
|
||||
var ny: int = oy + dy
|
||||
if ny < 0 or ny >= _height:
|
||||
continue
|
||||
for ox in range(old_w):
|
||||
var nx: int = ox + dx
|
||||
if nx < 0 or nx >= _width:
|
||||
continue
|
||||
var old_idx := (oy * old_w + ox) * 3
|
||||
var new_idx := (ny * _width + nx) * 3
|
||||
_tint_bytes[new_idx + 0] = old_tint[old_idx + 0]
|
||||
_tint_bytes[new_idx + 1] = old_tint[old_idx + 1]
|
||||
_tint_bytes[new_idx + 2] = old_tint[old_idx + 2]
|
||||
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
|
||||
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
|
||||
|
||||
_prev_visible.clear()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
# Resize if map bounds changed
|
||||
var tiles := GameState.visible_tiles
|
||||
if tiles.size() > 0:
|
||||
var new_bounds := _compute_bounds(tiles)
|
||||
# Grow bounds to include newly visible tiles — never shrink, so explored
|
||||
# tiles behind the player stay in the texture and render as deep fog
|
||||
# instead of black. Exploration data is preserved across resizes.
|
||||
# Use visible_positions (always populated from server snapshots) instead of
|
||||
# visible_tiles, which stays empty in live server mode because the server
|
||||
# sends tile_kind but game_state.gd's population check expects "type".
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
if positions.size() > 0:
|
||||
var new_bounds := _grow_bounds_from_positions(positions)
|
||||
if new_bounds != map_bounds:
|
||||
_resize(new_bounds)
|
||||
|
||||
var ox: int = map_bounds.position.x
|
||||
var oy: int = map_bounds.position.y
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
var sectors: Dictionary = GameState.visibility_sectors
|
||||
|
||||
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
|
||||
|
||||
# 1. Clear visibility, then write current LOS
|
||||
# 1. Clear visibility, then write current LOS (all tiles are Forward)
|
||||
_vis_bytes.fill(VIS_HIDDEN)
|
||||
for pos in positions:
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
var sector: String = sectors.get(pos, "Forward")
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||
# #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly,
|
||||
# but NOT in visible_positions so they don't persist as explored memory.
|
||||
for pos in GameState.boundary_positions:
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||
visibility_texture.update(_vis_image)
|
||||
|
||||
@@ -106,24 +191,58 @@ func update_from_state() -> void:
|
||||
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||
exploration_texture.update(_exp_image)
|
||||
|
||||
# 3. Zone tint: write zone temperature color for currently visible tiles.
|
||||
# Zone data is stable (tile zone never changes) so we only write on first sight.
|
||||
# Data persists in _tint_bytes across frames and across resizes.
|
||||
# visible_tiles carries zone_id per tile (populated from snapshot "tiles" or
|
||||
# "visible_tiles" with type field — see game_state.gd apply_snapshot).
|
||||
var tiles := GameState.visible_tiles
|
||||
var tint_dirty := false
|
||||
for tile in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
var zone_id: String = str(tile.get("zone_id", ""))
|
||||
if zone_id.is_empty():
|
||||
continue
|
||||
var tint_color: Color = ZONE_TINTS.get(zone_id, ZONE_TINT_DEFAULT)
|
||||
var px: int = int(tile.x) - ox
|
||||
var py: int = int(tile.y) - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
var tint_idx := (py * _width + px) * 3
|
||||
var new_r := int(tint_color.r * 255.0)
|
||||
var new_g := int(tint_color.g * 255.0)
|
||||
var new_b := int(tint_color.b * 255.0)
|
||||
# Only update if different from current (avoid spurious texture uploads)
|
||||
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
|
||||
_tint_bytes[tint_idx + 0] = new_r
|
||||
_tint_bytes[tint_idx + 1] = new_g
|
||||
_tint_bytes[tint_idx + 2] = new_b
|
||||
tint_dirty = true
|
||||
if tint_dirty:
|
||||
_tint_image.set_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
|
||||
zone_tint_texture.update(_tint_image)
|
||||
|
||||
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
|
||||
_prev_visible = positions.duplicate()
|
||||
|
||||
|
||||
func _compute_bounds(tiles: Array) -> Rect2i:
|
||||
func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
|
||||
## Compute bounds from visible_positions (Dictionary[Vector2i, bool]).
|
||||
var min_x := 999999
|
||||
var min_y := 999999
|
||||
var max_x := -999999
|
||||
var max_y := -999999
|
||||
for tile in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
min_x = mini(min_x, int(tile.x))
|
||||
min_y = mini(min_y, int(tile.y))
|
||||
max_x = maxi(max_x, int(tile.x))
|
||||
max_y = maxi(max_y, int(tile.y))
|
||||
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
|
||||
for pos in positions:
|
||||
min_x = mini(min_x, pos.x)
|
||||
min_y = mini(min_y, pos.y)
|
||||
max_x = maxi(max_x, pos.x)
|
||||
max_y = maxi(max_y, pos.y)
|
||||
if min_x > max_x:
|
||||
return Rect2i(0, 0, 1, 1)
|
||||
# Margin for fog gradient bleed at edges
|
||||
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
|
||||
return map_bounds
|
||||
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
|
||||
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
|
||||
return tile_bounds
|
||||
return map_bounds.merge(tile_bounds)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
|
||||
|
||||
# v2 fields (D-015, D-031)
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
||||
@@ -66,6 +67,12 @@ var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode fla
|
||||
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||
var insert_active: bool = true
|
||||
|
||||
# #175: World seed for deterministic simulation (D-010, D-029).
|
||||
# Set by SessionManager.new_game(), sent to server via StartupMessage in SimBridge.
|
||||
# Same seed → same EntanglementConfig → same NPC population across playthroughs.
|
||||
# Persists for the session lifetime; not overwritten by apply_snapshot().
|
||||
var world_seed: int = 0
|
||||
|
||||
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
@@ -75,6 +82,16 @@ var rng_seed: Variant = null
|
||||
# One-shot: consumed by main.gd after display, then set back to null.
|
||||
var save_result: Variant = null
|
||||
|
||||
# v18 fields (#580): debug console response from server.
|
||||
# {command: String, text: String, success: bool} or null.
|
||||
# One-shot: consumed by main.gd and forwarded to DebugConsole, then set to null.
|
||||
var debug_response: Variant = null
|
||||
|
||||
# #257: Pending load path — set by main menu "Load Game" selection.
|
||||
# main.gd sends LOAD_GAME on startup if non-empty, then clears this field.
|
||||
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
|
||||
var pending_load_path: String = ""
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
@@ -109,14 +126,17 @@ var medium_sound_events: Array = []
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
|
||||
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
# D-020: Read directly from snapshot "zone_id" field.
|
||||
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
|
||||
# Empty string when zone_id field absent.
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
@@ -140,12 +160,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
# Prefer server-sent value; fall back to client-side accumulation until server populates.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
stationary_ticks = snapshot.stationary_ticks
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
# DEPRECATED fallback — client-side accumulation. Remove when server sends
|
||||
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
|
||||
# driving state on the client). Server tracks this in ListeningFocus component.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
@@ -290,34 +317,55 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
save_result = null
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
debug_response = snapshot.debug_response
|
||||
else:
|
||||
debug_response = null
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
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 ""
|
||||
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||
# Prefer server-sent top-level value; fall back to client-side tile lookup until
|
||||
# server populates top-level "zone_id" in ObserverSnapshot.
|
||||
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
current_zone_id = snapshot.zone_id
|
||||
else:
|
||||
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
|
||||
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
|
||||
# identity on the client via tile iteration). Server sends zone_id per
|
||||
# VisibleTile but not as a top-level snapshot field.
|
||||
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
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
# Derives visible_positions when not explicitly provided (real server mode).
|
||||
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
visible_positions.clear()
|
||||
boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
visibility_sectors[pos] = vtile.visibility
|
||||
if not has_explicit_positions:
|
||||
visibility_sectors[pos] = vis_sector
|
||||
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
|
||||
# explored — they don't update the player's exploration memory when they leave LOS.
|
||||
if vis_sector == "BoundaryWall":
|
||||
boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
visible_positions[pos] = true
|
||||
|
||||
@@ -24,6 +24,7 @@ enum Action {
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
@@ -31,12 +31,27 @@ func new_game() -> String:
|
||||
save_path, error_string(err)])
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
# #175: Generate world_seed for deterministic simulation (D-010, D-029).
|
||||
# Combines two randi() calls (u32 each) into 63-bit entropy range.
|
||||
# Mask bit 31 of the upper word before shifting to prevent signed overflow:
|
||||
# GDScript int is i64 — if bit 63 is set, MessagePack encodes as negative,
|
||||
# and Rust rmp_serde rejects negative values when deserializing as u64.
|
||||
GameState.world_seed = ((rng.randi() & 0x7FFFFFFF) << 32) | rng.randi()
|
||||
|
||||
# Persist world_seed to save directory so resume_game() can restore it.
|
||||
# Without this, loaded sessions would send seed=0, breaking D-010 determinism.
|
||||
_write_seed_file(save_path, GameState.world_seed)
|
||||
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed from the save directory for D-010 deterministic replay.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
GameState.world_seed = _read_seed_file(save_path)
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
@@ -111,6 +126,26 @@ func _cleanup_quit_dialog() -> void:
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
## Write world_seed to a file in the save directory for session persistence.
|
||||
func _write_seed_file(save_path: String, seed: int) -> void:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error()))
|
||||
return
|
||||
file.store_64(seed)
|
||||
|
||||
|
||||
## Read world_seed from save directory. Returns 0 if file missing (legacy saves).
|
||||
## Masks the sign bit on read: save files written before the signed-overflow fix
|
||||
## may contain negative i64 values that Rust rmp_serde rejects as u64.
|
||||
func _read_seed_file(save_path: String) -> int:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("SessionManager: no seed file in %s — using seed=0 (legacy save)" % save_path)
|
||||
return 0
|
||||
return file.get_64() & 0x7FFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://b357ok64jc8vp
|
||||
@@ -231,6 +231,26 @@ func _process(delta: float) -> void:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Send startup message with world_seed (#175, D-010/D-029).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
var reason := "Failed to send startup message: %s" % error_string(send_err)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
else:
|
||||
var reason := "Failed to encode startup message"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
handshake_complete.emit(server_version)
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
return
|
||||
@@ -396,6 +416,8 @@ static func action_enum_to_wire(action: int) -> String:
|
||||
return "SaveGame" # #554: F5 quicksave (D-085)
|
||||
InputMapper.Action.LOAD_GAME:
|
||||
return "LoadGame" # #554: F6 quickload (D-085)
|
||||
InputMapper.Action.DEBUG_COMMAND:
|
||||
return "DebugCommand" # #581: debug console command dispatch
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
@@ -49,44 +49,6 @@ func reload() -> void:
|
||||
|
||||
## Parse YAML with arbitrary nesting depth.
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
static func _parse_yaml(text: String) -> Dictionary:
|
||||
var strings := {}
|
||||
var stack: Array = [] # [[indent, key], ...]
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.begins_with("#"):
|
||||
continue
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
var colon_pos := content.find(":")
|
||||
if colon_pos < 0:
|
||||
continue
|
||||
var key := content.substr(0, colon_pos).strip_edges()
|
||||
var val := content.substr(colon_pos + 1).strip_edges()
|
||||
# Trailing comment without a value — treat as section header
|
||||
if val.begins_with("#"):
|
||||
val = ""
|
||||
# Pop sections at same or deeper indent
|
||||
while stack.size() > 0 and stack.back()[0] >= indent:
|
||||
stack.pop_back()
|
||||
if val.is_empty():
|
||||
# Section header — push onto stack
|
||||
stack.push_back([indent, key])
|
||||
else:
|
||||
# Leaf value — extract from quotes or strip inline comment
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
val = val.substr(1, end_quote - 1)
|
||||
else:
|
||||
val = val.substr(1)
|
||||
else:
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
var dotted_key := ""
|
||||
for entry in stack:
|
||||
dotted_key += entry[1] + "."
|
||||
dotted_key += key
|
||||
strings[dotted_key] = val
|
||||
return strings
|
||||
return YamlParser.parse_flat(text)
|
||||
|
||||
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
#
|
||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
||||
# -- YAML parsing --------------------------------------------------------------
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
## Delegates to YamlParser.parse() (#560).
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
return YamlParser.parse(text)
|
||||
|
||||
@@ -21,6 +21,8 @@ extends Node2D
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
@@ -33,6 +35,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (
|
||||
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 _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
@@ -48,6 +51,17 @@ func _ready() -> void:
|
||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
|
||||
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
|
||||
# In live mode, state is CONNECTING — signal handler dispatches once connected.
|
||||
if not GameState.pending_load_path.is_empty():
|
||||
if loading_screen:
|
||||
loading_screen.show_loading()
|
||||
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
||||
_dispatch_pending_load()
|
||||
else:
|
||||
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
|
||||
|
||||
# Camera anchor: snap to player position before the first frame renders.
|
||||
# In test mode poll_snapshot() returns synchronously — position is set
|
||||
# immediately. In live mode the snapshot isn't available yet — _process
|
||||
@@ -65,11 +79,61 @@ func _ready() -> void:
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
||||
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
|
||||
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
|
||||
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
|
||||
_router = SnapshotEventRouter.new()
|
||||
# Always-run: child nodes that update from GameState on every snapshot tick.
|
||||
if world_renderer:
|
||||
_router.register_always(world_renderer.update_from_state)
|
||||
_router.register_always(_propagate_insert_state)
|
||||
_router.register_always(_update_interaction_list)
|
||||
if inventory_grid:
|
||||
_router.register_always(inventory_grid.update_from_state)
|
||||
if stance_indicator:
|
||||
_router.register_always(stance_indicator.update_from_state)
|
||||
if fog_entities:
|
||||
_router.register_always(fog_entities.update_from_state)
|
||||
_router.register_always(_play_recognition_chimes)
|
||||
if gauntlet_hud:
|
||||
_router.register_always(gauntlet_hud.update_from_state)
|
||||
if checklist_overlay:
|
||||
_router.register_always(checklist_overlay.update_from_state)
|
||||
if time_display:
|
||||
_router.register_always(time_display.update_from_state)
|
||||
if journal_panel:
|
||||
_router.register_always(journal_panel.update_from_state)
|
||||
if debug_overlay:
|
||||
_router.register_always(debug_overlay.update_from_state)
|
||||
_router.register_always(_play_close_sound_events)
|
||||
_router.register_always(_update_zone)
|
||||
_router.register_always(_update_listening_focus)
|
||||
_router.register_always(_consume_examine_result)
|
||||
# Keyed: consume methods guarded by specific snapshot fields.
|
||||
_router.register("current_monologue", _consume_monologue)
|
||||
_router.register("current_dialogue", _consume_dialogue)
|
||||
_router.register("conversation_events", _consume_conversation_events)
|
||||
_router.register("conversation_ended", _consume_conversation_ended)
|
||||
_router.register("dialogue_response", _consume_dialogue_response)
|
||||
_router.register("save_result", _consume_save_result)
|
||||
_router.register("debug_response", _consume_debug_response)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
|
||||
if settings_dialog and debug_console:
|
||||
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
|
||||
|
||||
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
|
||||
if debug_console:
|
||||
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
@@ -89,92 +153,9 @@ func _process(delta: float) -> void:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# Update renderers with new state
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
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
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
# D-065: Update inventory grid
|
||||
if inventory_grid and inventory_grid.has_method("update_from_state"):
|
||||
inventory_grid.update_from_state()
|
||||
|
||||
# D-053: Update stance indicator
|
||||
if stance_indicator and stance_indicator.has_method("update_from_state"):
|
||||
stance_indicator.update_from_state()
|
||||
|
||||
# D-059/D-060: Update fog entity visualization (#431)
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
||||
_play_recognition_chimes()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("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)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_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)
|
||||
_consume_monologue()
|
||||
|
||||
# D-061: Show dialogue if server sent one this tick (#434)
|
||||
_consume_dialogue()
|
||||
|
||||
# #535: Consume overheard conversation events and responses
|
||||
_consume_conversation_events()
|
||||
_consume_conversation_ended()
|
||||
_consume_dialogue_response()
|
||||
|
||||
# #554: Show save/load result notification
|
||||
_consume_save_result()
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||
@@ -203,6 +184,15 @@ func _process(delta: float) -> void:
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails)
|
||||
if input.action == InputMapper.Action.LOAD_GAME:
|
||||
var err := SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
if err == OK and loading_screen:
|
||||
loading_screen.show_loading()
|
||||
elif err != OK:
|
||||
push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err))
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
@@ -247,6 +237,32 @@ func _process(delta: float) -> void:
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
func _propagate_insert_state() -> void:
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer:
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise.
|
||||
func _update_interaction_list() -> void:
|
||||
if not interaction_list:
|
||||
return
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
@@ -346,7 +362,8 @@ func _consume_dialogue() -> void:
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", [])
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
@@ -376,20 +393,25 @@ func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
|
||||
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# #554: Show save/load result notification from server response.
|
||||
# #554/#257: Show save/load result notification; hide loading screen on load complete.
|
||||
func _consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null # consume once
|
||||
# #257: Dismiss loading screen regardless of success/failure
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading()
|
||||
var msg: String
|
||||
if result.get("success", false):
|
||||
if result.get("kind", "") == "save":
|
||||
@@ -405,6 +427,14 @@ func _consume_save_result() -> void:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func _consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
@@ -456,12 +486,54 @@ func _on_dialogue_dismissed() -> void:
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
|
||||
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
|
||||
func _on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
|
||||
func _on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
|
||||
func _on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
|
||||
# pending_load_path is set by main_menu.gd before scene change.
|
||||
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load):
|
||||
SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load)
|
||||
_dispatch_pending_load()
|
||||
|
||||
|
||||
func _dispatch_pending_load() -> void:
|
||||
var load_path := GameState.pending_load_path
|
||||
if load_path.is_empty():
|
||||
return
|
||||
GameState.pending_load_path = ""
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.LOAD_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": load_path},
|
||||
})
|
||||
if err != OK:
|
||||
push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err))
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading(false)
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 15
|
||||
const PROTOCOL_VERSION: int = 18
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -244,6 +244,35 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"error": raw_save.get("error"),
|
||||
}
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
# {command: String, text: String, success: bool}
|
||||
var debug_response: Variant = null
|
||||
var raw_debug: Variant = raw.get("debug_response")
|
||||
if raw_debug is Dictionary:
|
||||
debug_response = {
|
||||
"command": str(raw_debug.get("command", "")),
|
||||
"text": str(raw_debug.get("text", "")),
|
||||
"success": bool(raw_debug.get("success", false)),
|
||||
}
|
||||
|
||||
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
||||
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||
var stationary_ticks: Variant = null
|
||||
var raw_st: Variant = raw.get("stationary_ticks")
|
||||
if raw_st != null:
|
||||
stationary_ticks = int(raw_st)
|
||||
|
||||
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
|
||||
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
|
||||
# When server populates this, client-side tile iteration fallback in game_state.gd
|
||||
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||
var zone_id: Variant = null
|
||||
var raw_zid: Variant = raw.get("zone_id")
|
||||
if raw_zid is String:
|
||||
zone_id = raw_zid
|
||||
|
||||
# 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}]}
|
||||
@@ -302,6 +331,9 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"examine_result": examine_result,
|
||||
"player_knowledge": player_knowledge,
|
||||
"save_result": save_result,
|
||||
"debug_response": debug_response,
|
||||
"stationary_ticks": stationary_ticks,
|
||||
"zone_id": zone_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -407,6 +439,18 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng with the world seed (D-010, D-029).
|
||||
static func encode_startup_message(world_seed: int) -> PackedByteArray:
|
||||
var msg := {"world_seed": world_seed}
|
||||
var result = Messagepack.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode a PlayerInput to MessagePack bytes.
|
||||
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://2p33ypi2y2kv
|
||||
@@ -4,6 +4,9 @@ extends Node2D
|
||||
## Reads textures from FogState autoload, positions rect to cover viewport.
|
||||
## Architecture: docs/architecture/fog-shader-spec.md
|
||||
|
||||
signal fog_noise_ready
|
||||
var _noise_ready: bool = false
|
||||
|
||||
var _fog_rect: ColorRect
|
||||
var _shader_mat: ShaderMaterial
|
||||
|
||||
@@ -36,10 +39,11 @@ func _ready() -> void:
|
||||
noise_tex.width = 256
|
||||
noise_tex.height = 256
|
||||
noise_tex.seamless = true
|
||||
noise_tex.changed.connect(func(): _noise_ready = true; fog_noise_ready.emit())
|
||||
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
|
||||
|
||||
print("FogShader: Initialized (D-059 5-layer)")
|
||||
print("FogShader: Initialized (D-059 3-state)")
|
||||
|
||||
|
||||
func update_fog() -> void:
|
||||
@@ -68,4 +72,6 @@ func update_fog() -> void:
|
||||
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
|
||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
||||
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
|
||||
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
|
||||
var t: float = FogState.override_time if FogState.override_time >= 0.0 else Time.get_ticks_msec() / 1000.0
|
||||
_shader_mat.set_shader_parameter("time", t)
|
||||
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)
|
||||
|
||||
@@ -64,11 +64,17 @@ func _setup_tileset() -> void:
|
||||
tile_set = ts
|
||||
|
||||
# Update tiles from snapshot data
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)}
|
||||
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
||||
# NOT the Godot scene z_index (which controls render order within a floor).
|
||||
# This node only renders floor-level 0. Higher floor levels will be handled
|
||||
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
|
||||
#
|
||||
# BoundaryWall tiles (#585): visibility="BoundaryWall" tiles (wall tiles 1 step beyond
|
||||
# LOS boundary) are rendered normally here — they have a "type" field from protocol.gd
|
||||
# so they composite correctly under the fog shader. The fog/exploration exemption is
|
||||
# handled in fog_state.gd (VIS_FORWARD without EXP_VISIBLE) and game_state.gd
|
||||
# (boundary_positions not visible_positions). No special handling needed in this method.
|
||||
func update_tiles(tiles: Array) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name SnapshotEventRouter
|
||||
## Routes snapshot fields to registered handlers (D-020, #559).
|
||||
##
|
||||
## Decouples main.gd from knowing which child node handles which snapshot field.
|
||||
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
|
||||
##
|
||||
## Two handler types:
|
||||
## - Keyed: called only when the snapshot contains a specific field.
|
||||
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
|
||||
##
|
||||
## All handlers are zero-argument callables — they read from GameState directly.
|
||||
## This preserves GameState as the single source of truth post-apply_snapshot().
|
||||
|
||||
## Keyed handlers: field_name → Array[Callable]
|
||||
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
|
||||
var _keyed: Dictionary = {} # String → Array[Callable]
|
||||
|
||||
## Always handlers: called every dispatch regardless of snapshot content.
|
||||
var _always: Array[Callable] = []
|
||||
|
||||
|
||||
## Register a handler for a specific snapshot field key.
|
||||
## Handler is called (with no arguments) when snapshot.has(field) is true.
|
||||
## Multiple handlers per field are supported — they run in registration order.
|
||||
func register(field: String, handler: Callable) -> void:
|
||||
if not _keyed.has(field):
|
||||
_keyed[field] = []
|
||||
_keyed[field].append(handler)
|
||||
|
||||
|
||||
## Register a handler that runs every dispatch tick (not keyed to a field).
|
||||
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
|
||||
func register_always(handler: Callable) -> void:
|
||||
_always.append(handler)
|
||||
|
||||
|
||||
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
|
||||
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
|
||||
func dispatch(snapshot: Dictionary) -> void:
|
||||
for handler in _always:
|
||||
handler.call()
|
||||
for field in _keyed:
|
||||
if snapshot.has(field):
|
||||
for handler in _keyed[field]:
|
||||
handler.call()
|
||||
@@ -0,0 +1 @@
|
||||
uid://drppri4b46v80
|
||||
@@ -0,0 +1,163 @@
|
||||
class_name YamlParser
|
||||
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
|
||||
##
|
||||
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
|
||||
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
|
||||
## (as UIStrings._parse_yaml() requires).
|
||||
##
|
||||
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
|
||||
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
|
||||
##
|
||||
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
|
||||
|
||||
|
||||
## Parse YAML text into a hierarchical Dictionary.
|
||||
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
|
||||
## Values are type-inferred: bool, int, float, or String.
|
||||
static func parse(text: String) -> Dictionary:
|
||||
var root: Dictionary = {}
|
||||
# Stack: [{indent: int, key: String}] — path of open section headers
|
||||
var stack: Array = []
|
||||
# Array state
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
var stripped := raw_line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
|
||||
var content: String = stripped.strip_edges()
|
||||
|
||||
# --- Array item (- key: value) ---
|
||||
if content.begins_with("- "):
|
||||
# First item: convert parent section's {} placeholder to []
|
||||
if current_array == null and stack.size() > 0:
|
||||
var parent := _node_at(root, stack, true)
|
||||
var arr_key: String = stack.back()["key"]
|
||||
var new_arr: Array = []
|
||||
parent[arr_key] = new_arr
|
||||
current_array = new_arr
|
||||
array_parent_indent = stack.back()["indent"]
|
||||
# Flush previous item and start a new one
|
||||
if current_item != null:
|
||||
current_array.append(current_item)
|
||||
current_item = {}
|
||||
var rest: String = content.substr(2).strip_edges()
|
||||
var colon: int = rest.find(":")
|
||||
if colon >= 0:
|
||||
var k: String = rest.substr(0, colon).strip_edges()
|
||||
var v: String = rest.substr(colon + 1).strip_edges()
|
||||
current_item[k] = _parse_value(v)
|
||||
continue
|
||||
|
||||
# --- Continuation line within current array item ---
|
||||
if current_array != null and indent > array_parent_indent:
|
||||
var colon: int = content.find(":")
|
||||
if colon >= 0 and current_item != null:
|
||||
var k: String = content.substr(0, colon).strip_edges()
|
||||
var v: String = content.substr(colon + 1).strip_edges()
|
||||
current_item[k] = _parse_value(v)
|
||||
continue
|
||||
|
||||
# --- End of array (indent has returned to array level or above) ---
|
||||
if current_array != null:
|
||||
if current_item != null:
|
||||
current_array.append(current_item)
|
||||
current_item = null
|
||||
current_array = null
|
||||
array_parent_indent = -1
|
||||
if stack.size() > 0:
|
||||
stack.pop_back() # pop the array-owning key
|
||||
|
||||
# --- Regular key: value or section header ---
|
||||
var colon: int = content.find(":")
|
||||
if colon < 0:
|
||||
continue
|
||||
var key: String = content.substr(0, colon).strip_edges()
|
||||
var val_str: String = content.substr(colon + 1).strip_edges()
|
||||
|
||||
# Pop sections at the same or deeper indent (we're back at a shallower level)
|
||||
while stack.size() > 0 and stack.back()["indent"] >= indent:
|
||||
stack.pop_back()
|
||||
|
||||
var node: Dictionary = _node_at(root, stack, false)
|
||||
|
||||
if val_str.is_empty() or val_str.begins_with("#"):
|
||||
# Section header — create nested dict (may become Array if - items follow)
|
||||
node[key] = {}
|
||||
stack.push_back({"indent": indent, "key": key})
|
||||
else:
|
||||
node[key] = _parse_value(val_str)
|
||||
|
||||
# Flush the last array item if the file ended inside an array
|
||||
if current_array != null and current_item != null:
|
||||
current_array.append(current_item)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
## Convenience: parse text and flatten to dotted-key format in one call.
|
||||
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
|
||||
static func parse_flat(text: String) -> Dictionary:
|
||||
return flatten(parse(text))
|
||||
|
||||
|
||||
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
|
||||
## {"a": {"b": "v"}} → {"a.b": "v"}
|
||||
## Arrays are skipped — dotted-key format does not represent them.
|
||||
## All values are converted to String (UIStrings stores display text, not typed data).
|
||||
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for k in d:
|
||||
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
|
||||
var v = d[k]
|
||||
if v is Dictionary:
|
||||
result.merge(flatten(v, full_key))
|
||||
elif not v is Array:
|
||||
result[full_key] = str(v)
|
||||
return result
|
||||
|
||||
|
||||
## Parse a single YAML value string into a typed GDScript value.
|
||||
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
# Strip inline comment outside quotes
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos: int = val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
# Quoted string — extract content between quotes
|
||||
if val.begins_with("\""):
|
||||
var end_quote: int = val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
# Boolean
|
||||
if val == "true": return true
|
||||
if val == "false": return false
|
||||
# Float (must have decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
# Plain string
|
||||
return val
|
||||
|
||||
|
||||
## Navigate root following the stack key path.
|
||||
## parent=true: navigate one level less (returns the parent node, not the leaf).
|
||||
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
|
||||
var node: Dictionary = root
|
||||
var depth: int = stack.size() - (1 if parent else 0)
|
||||
for i in range(depth):
|
||||
var k: String = stack[i]["key"]
|
||||
if node.has(k) and node[k] is Dictionary:
|
||||
node = node[k]
|
||||
else:
|
||||
break
|
||||
return node
|
||||
@@ -0,0 +1 @@
|
||||
uid://cix55xks85vl8
|
||||
@@ -1,15 +1,17 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
|
||||
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
|
||||
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
|
||||
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
|
||||
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
|
||||
// Layer 5: Unexplored, no maps — solid near-black #12141a
|
||||
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
|
||||
// State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (3-4 tile radius)
|
||||
// State 2: Explored (out of cone) — light fog overlay, alpha 0.25-0.35, zone temperature tint,
|
||||
// 8-10s Perlin breathe. Art and information preserved, just "not fresh" (D-015).
|
||||
// State 3: Unexplored — solid near-black #12141a
|
||||
// D-033: Entity colors are NOT affected — they render above the fog overlay (z-layer 5).
|
||||
// D-046: Zone temperature tint from zone_tint_tex — warm=bar, cool=hub, neutral=corridor.
|
||||
// D-077: zone_tint_tex populated per-tile from server zone_id via fog_state.gd.
|
||||
|
||||
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
|
||||
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // nearest: zones have hard boundaries (D-073)
|
||||
uniform sampler2D noise_tex : filter_linear, repeat_enable;
|
||||
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
|
||||
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
|
||||
@@ -17,57 +19,99 @@ uniform vec2 map_offset; // map_bounds.position (tiles)
|
||||
uniform vec2 map_size; // map_bounds.size (tiles)
|
||||
uniform float tile_size; // Pixels per sim tile
|
||||
uniform float time; // Seconds since start
|
||||
uniform bool debug_exploration = false; // When true, render raw exploration texture
|
||||
|
||||
// D-059 fog layer colors
|
||||
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
|
||||
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
|
||||
|
||||
// D-059 thresholds (after bilinear filtering)
|
||||
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
|
||||
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
|
||||
const float PERIPHERAL_LOW = 0.55; // Below this: transition to deep/unexplored
|
||||
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
|
||||
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
|
||||
float sample_visibility(vec2 uv) {
|
||||
vec2 t = 2.0 / map_size;
|
||||
float sum = 0.0;
|
||||
float weight = 0.0;
|
||||
for (float dy = -3.0; dy <= 3.0; dy += 1.0) {
|
||||
for (float dx = -3.0; dx <= 3.0; dx += 1.0) {
|
||||
float w = exp(-(dx * dx + dy * dy) / 8.0);
|
||||
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
|
||||
sum += texture(visibility_tex, sample_uv).r * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return sum / weight;
|
||||
}
|
||||
|
||||
// Soft gradient on exploration boundary (5x5, sigma 1.5).
|
||||
// Prevents hard tile-stepped staircase at explored/unexplored edge.
|
||||
float sample_exploration(vec2 uv) {
|
||||
vec2 t = 1.0 / map_size;
|
||||
float sum = 0.0;
|
||||
float weight = 0.0;
|
||||
for (float dy = -2.0; dy <= 2.0; dy += 1.0) {
|
||||
for (float dx = -2.0; dx <= 2.0; dx += 1.0) {
|
||||
float w = exp(-(dx * dx + dy * dy) / 4.5);
|
||||
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
|
||||
sum += texture(exploration_tex, sample_uv).r * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return sum / weight;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
|
||||
vec2 world_px = rect_pos + UV * rect_sz;
|
||||
vec2 tile = world_px / tile_size;
|
||||
|
||||
// Map tile coordinate to texture UV
|
||||
vec2 tex_uv = (tile - map_offset) / map_size;
|
||||
|
||||
// Outside known map → unexplored
|
||||
// Outside known map -> unexplored
|
||||
// Note: no early return — fragment() in OpenGL3 compat doesn't support return.
|
||||
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
} else {
|
||||
float vis = texture(visibility_tex, tex_uv).r;
|
||||
float explored = texture(exploration_tex, tex_uv).r;
|
||||
|
||||
if (vis > PERIPHERAL_LOW) {
|
||||
// In or near vision cone
|
||||
if (vis > CLEAR_THRESHOLD) {
|
||||
// Layer 1: Clear — soft edge gradient
|
||||
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
|
||||
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
|
||||
} else {
|
||||
// Layer 2: Light fog (peripheral + forward edge)
|
||||
// D-059: animated Perlin noise, 8-10s cycle
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
|
||||
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
|
||||
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
|
||||
COLOR = vec4(DARK_OVERLAY, alpha);
|
||||
}
|
||||
} else if (explored > 0.3) {
|
||||
// Layer 3: Deep fog (previously explored, no longer in LOS)
|
||||
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
|
||||
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
|
||||
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
|
||||
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
|
||||
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
|
||||
COLOR = vec4(tint_color, alpha);
|
||||
// Debug mode: render raw exploration texture (bypass fog rendering).
|
||||
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
|
||||
} else if (debug_exploration) {
|
||||
float explored_dbg = texture(exploration_tex, tex_uv).r;
|
||||
if (explored_dbg > 0.9) {
|
||||
COLOR = vec4(0.0, explored_dbg, 0.0, 0.8); // Green: currently visible
|
||||
} else if (explored_dbg > 0.1) {
|
||||
COLOR = vec4(0.0, 0.0, explored_dbg * 2.0, 0.8); // Blue: explored
|
||||
} else {
|
||||
// Layer 5: Unexplored, no maps — information zero
|
||||
COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored
|
||||
}
|
||||
|
||||
} else {
|
||||
float vis_raw = texture(visibility_tex, tex_uv).r;
|
||||
float vis = sample_visibility(tex_uv);
|
||||
float explored_raw = texture(exploration_tex, tex_uv).r;
|
||||
float explored = sample_exploration(tex_uv);
|
||||
|
||||
// Prevent gradient bleed into never-explored tiles (use raw, unblurred value)
|
||||
if (explored_raw < 0.01 && vis_raw < 0.01) {
|
||||
vis = 0.0;
|
||||
}
|
||||
|
||||
if (explored < 0.01 && vis < 0.01) {
|
||||
// Unexplored: solid near-black — information zero
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
} else {
|
||||
// Fog noise — 8-10s breathe cycle, ±0.05 symmetric around baseline
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float fog_alpha = 0.30 + (noise_val * 2.0 - 1.0) * 0.05; // 0.25-0.35
|
||||
|
||||
// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
|
||||
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
|
||||
|
||||
// Clarity ramp: transparent inside cone, light fog at edges and beyond
|
||||
float clarity = smoothstep(0.0, 0.85, vis);
|
||||
float alpha = mix(fog_alpha, 0.0, clarity);
|
||||
vec3 color = mix(zone_tint, vec3(0.0), clarity);
|
||||
|
||||
// Soft edge between explored and unexplored (blurred to avoid staircase)
|
||||
float exp_fade = smoothstep(0.0, 0.3, explored);
|
||||
alpha = mix(1.0, alpha, exp_fade);
|
||||
color = mix(UNEXPLORED_COLOR, color, exp_fade);
|
||||
|
||||
COLOR = vec4(color, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://qlxaaip3fqic"
|
||||
path="res://.godot/imported/cursor_menu.png-03cbe8a063e08efcd4283ce76f668ea1.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/cursor_menu.png"
|
||||
dest_files=["res://.godot/imported/cursor_menu.png-03cbe8a063e08efcd4283ce76f668ea1.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
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://oqbx8gwq1xta"
|
||||
path="res://.godot/imported/dialogue_open.png-2e5a2dd99abe2817e4bb7ede8f83b0bf.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/dialogue_open.png"
|
||||
dest_files=["res://.godot/imported/dialogue_open.png-2e5a2dd99abe2817e4bb7ede8f83b0bf.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
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b2hctv70ar7al"
|
||||
path="res://.godot/imported/dialogue_with_monologue.png-5193b3824e2195b1c7ca00bc005f9026.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/dialogue_with_monologue.png"
|
||||
dest_files=["res://.godot/imported/dialogue_with_monologue.png-5193b3824e2195b1c7ca00bc005f9026.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bw4kxsno66sv3"
|
||||
path="res://.godot/imported/fog_3state.png-b0193a736de6756c27053a3db24f9a12.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_3state.png"
|
||||
dest_files=["res://.godot/imported/fog_3state.png-b0193a736de6756c27053a3db24f9a12.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
|
||||
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://7ol3rcrpienc"
|
||||
path="res://.godot/imported/fog_boundary.png-7da06d227319f9c43ef7017ee082732a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_boundary.png"
|
||||
dest_files=["res://.godot/imported/fog_boundary.png-7da06d227319f9c43ef7017ee082732a.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fpncie7mnyay"
|
||||
path="res://.godot/imported/fog_boundary_replay.png-3e1acf3ef35450d1df230400b3003250.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_boundary_replay.png"
|
||||
dest_files=["res://.godot/imported/fog_boundary_replay.png-3e1acf3ef35450d1df230400b3003250.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
|
||||
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://71e1yuyjdxn7"
|
||||
path="res://.godot/imported/fog_debug.png-7d563b17499f08fbb82ec57bcdcc0e68.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_debug.png"
|
||||
dest_files=["res://.godot/imported/fog_debug.png-7d563b17499f08fbb82ec57bcdcc0e68.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2owpogcjnewr"
|
||||
path="res://.godot/imported/fog_diagonal.png-06416ee4adae921b884280dc73761ed3.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_diagonal.png"
|
||||
dest_files=["res://.godot/imported/fog_diagonal.png-06416ee4adae921b884280dc73761ed3.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
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dufvsap028rfw"
|
||||
path="res://.godot/imported/fog_live_hub.png-a2756ff6cb45af238cc0a32ba70a705a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_live_hub.png"
|
||||
dest_files=["res://.godot/imported/fog_live_hub.png-a2756ff6cb45af238cc0a32ba70a705a.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dmqmnfrjrk1u"
|
||||
path="res://.godot/imported/fog_live_replay.png-4604646d390fbfac6491f513f667c5c6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_live_replay.png"
|
||||
dest_files=["res://.godot/imported/fog_live_replay.png-4604646d390fbfac6491f513f667c5c6.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dgdt2rmql6hbb"
|
||||
path="res://.godot/imported/fog_theater_replay.png-ef10fe62d9da045193422127e48853ba.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_theater_replay.png"
|
||||
dest_files=["res://.godot/imported/fog_theater_replay.png-ef10fe62d9da045193422127e48853ba.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b8weq3wp6tybx"
|
||||
path="res://.godot/imported/fog_zone_tint.png-5a5cd0f27eaf991e1b39709de687dcf4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_zone_tint.png"
|
||||
dest_files=["res://.godot/imported/fog_zone_tint.png-5a5cd0f27eaf991e1b39709de687dcf4.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c5r4tk4yafj2r"
|
||||
path="res://.godot/imported/hud_default.png-d3286d0b35f8bfaeab8436d9645c0c0d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/hud_default.png"
|
||||
dest_files=["res://.godot/imported/hud_default.png-d3286d0b35f8bfaeab8436d9645c0c0d.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://0pym2n4nq12a"
|
||||
path="res://.godot/imported/minimap_stance.png-abbfd0259479cd17651bee48cdc7f7b8.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/minimap_stance.png"
|
||||
dest_files=["res://.godot/imported/minimap_stance.png-abbfd0259479cd17651bee48cdc7f7b8.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
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cc5nmdpumq6t"
|
||||
path="res://.godot/imported/npc_in_fog.png-e8a24e029e5c640a7daee7d3f2838bdc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/npc_in_fog.png"
|
||||
dest_files=["res://.godot/imported/npc_in_fog.png-e8a24e029e5c640a7daee7d3f2838bdc.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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ypniv2crdp8c
|
||||
@@ -250,7 +250,7 @@ func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
|
||||
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
|
||||
# Timer lifecycle, personal bests, room change, visibility.
|
||||
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
|
||||
# Spec: sprint-9/client.md #496. Ticket: tooling/db/ticket show 496.
|
||||
|
||||
func test_format_time_zero() -> void:
|
||||
# Static utility: 0 seconds → "00:00"
|
||||
@@ -476,7 +476,7 @@ func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
|
||||
|
||||
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
||||
# Pause/unpause lifecycle, wire guard, text render, state machine.
|
||||
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
|
||||
# Spec: sprint-9/client.md #495. Ticket: tooling/db/ticket show 495.
|
||||
|
||||
func test_bug_report_sends_pause_on_open() -> void:
|
||||
# start_capture() must send Pause to the server.
|
||||
|
||||
@@ -55,7 +55,9 @@ func test_fog_visibility_forward_tile() -> void:
|
||||
|
||||
|
||||
func test_fog_visibility_peripheral_tile() -> void:
|
||||
# P1 #4: Peripheral-sector tile writes VIS_PERIPHERAL (180) to _vis_bytes.
|
||||
# P1 #4: Server simplified to forward-only (Sprint 22, #569). All visible
|
||||
# tiles are now written as VIS_FORWARD regardless of visibility_sectors value.
|
||||
# Peripheral sector is no longer a distinct visual state.
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
@@ -65,8 +67,8 @@ func test_fog_visibility_peripheral_tile() -> void:
|
||||
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
|
||||
"Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL
|
||||
).is_equal(FogState.VIS_PERIPHERAL)
|
||||
"All visible tiles write VIS_FORWARD after forward-only simplification"
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
@@ -167,8 +167,8 @@ func test_entity_terrain_uses_object_color() -> void:
|
||||
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
|
||||
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[70] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
var node = renderer.entity_nodes[70] as Sprite2D
|
||||
assert_that(node.self_modulate).override_failure_message(
|
||||
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
||||
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
@@ -181,8 +181,8 @@ func test_entity_player_color_regardless_of_sector() -> void:
|
||||
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[80] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
var node = renderer.entity_nodes[80] as Sprite2D
|
||||
assert_that(node.self_modulate).override_failure_message(
|
||||
"Player color must be constant regardless of visibility sector"
|
||||
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
# Alpha should still be dimmed for Peripheral
|
||||
|
||||
@@ -10,7 +10,7 @@ extends GdUnitTestSuite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://scripts/ui/debug_overlay.gd"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
@@ -51,7 +51,7 @@ func after_test() -> void:
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://scripts/ui/debug_overlay.gd (#348)"
|
||||
"debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdsgybncfyu52
|
||||
@@ -0,0 +1,153 @@
|
||||
## Sprint 23 #573: dialogue speaker color binding tests.
|
||||
##
|
||||
## Verifies entity-ID-bound speaker color assignment in dialogue_box.gd:
|
||||
## round-robin palette allocation, same-entity reuse, conversation-end reset,
|
||||
## and fallback behavior when no entity ID is provided.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestDialogueSpeakerColors
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSpeakerColors: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# -- _assign_npc_color: round-robin assignment ---------------------------------
|
||||
|
||||
func test_assign_npc_color_returns_palette_color() -> void:
|
||||
## First call for an entity ID should return a color from the NPC palette.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color: Color = box._assign_npc_color(100)
|
||||
assert_that(color).override_failure_message(
|
||||
"_assign_npc_color must return a non-default color for a valid entity ID (#573)"
|
||||
).is_not_equal(box._speech_color)
|
||||
|
||||
|
||||
func test_assign_npc_color_same_entity_returns_same_color() -> void:
|
||||
## Repeated calls for the same entity ID must return the same color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color1: Color = box._assign_npc_color(200)
|
||||
var color2: Color = box._assign_npc_color(200)
|
||||
assert_that(color1).override_failure_message(
|
||||
"_assign_npc_color must return the same color for the same entity ID (#573)"
|
||||
).is_equal(color2)
|
||||
|
||||
|
||||
func test_assign_npc_color_different_entities_get_different_colors() -> void:
|
||||
## Different entity IDs should get different colors (within palette size).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color1: Color = box._assign_npc_color(300)
|
||||
var color2: Color = box._assign_npc_color(301)
|
||||
assert_that(color1).override_failure_message(
|
||||
"Different entity IDs must get different palette colors (#573)"
|
||||
).is_not_equal(color2)
|
||||
|
||||
|
||||
func test_assign_npc_color_negative_id_returns_speech_color() -> void:
|
||||
## Negative entity ID (no entity) should fall back to _speech_color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var color: Color = box._assign_npc_color(-1)
|
||||
assert_that(color).override_failure_message(
|
||||
"_assign_npc_color(-1) must return _speech_color fallback (#573)"
|
||||
).is_equal(box._speech_color)
|
||||
|
||||
|
||||
# -- Color registry cleared on conversation end --------------------------------
|
||||
|
||||
func test_color_registry_cleared_on_conversation_end() -> void:
|
||||
## After hide_dialogue(), the color registry must be empty so next
|
||||
## conversation starts fresh (avoids palette exhaustion).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [], 400)
|
||||
assert_bool(box._npc_entity_colors.has(400)).override_failure_message(
|
||||
"Entity color should be registered during conversation (#573)"
|
||||
).is_true()
|
||||
box.hide_dialogue()
|
||||
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
|
||||
"_npc_entity_colors must be cleared after conversation ends (#573)"
|
||||
).is_true()
|
||||
assert_int(box._next_npc_color).override_failure_message(
|
||||
"_next_npc_color must reset to 0 after conversation ends (#573)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_color_registry_reset_gives_fresh_assignment() -> void:
|
||||
## After conversation end + new conversation, same entity ID gets a color
|
||||
## (may differ from previous conversation — that's fine, per-conversation).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [], 500)
|
||||
var color1: Color = box._npc_entity_colors.get(500, Color.BLACK)
|
||||
box.hide_dialogue()
|
||||
box.show_dialogue("NPC", "Hi again.", [], 500)
|
||||
var color2: Color = box._npc_entity_colors.get(500, Color.BLACK)
|
||||
# Both should be valid palette colors (not BLACK fallback)
|
||||
assert_that(color1).override_failure_message(
|
||||
"First conversation color must be a palette color (#573)"
|
||||
).is_not_equal(Color.BLACK)
|
||||
assert_that(color2).override_failure_message(
|
||||
"Second conversation color must be a palette color (#573)"
|
||||
).is_not_equal(Color.BLACK)
|
||||
|
||||
|
||||
# -- show_dialogue entity ID threading ----------------------------------------
|
||||
|
||||
func test_show_dialogue_registers_npc_color() -> void:
|
||||
## show_dialogue with a valid entity ID must register the color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("Kael", "Welcome.", [], 600)
|
||||
assert_bool(box._npc_entity_colors.has(600)).override_failure_message(
|
||||
"show_dialogue must register entity color when npc_entity_id provided (#573)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_show_dialogue_without_entity_id_no_registration() -> void:
|
||||
## show_dialogue without entity ID should not register any color.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(box._npc_entity_colors.is_empty()).override_failure_message(
|
||||
"show_dialogue without entity ID must not register colors (#573)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- append_dialogue_response defensive guard ----------------------------------
|
||||
|
||||
func test_append_dialogue_response_registers_color_if_missing() -> void:
|
||||
## append_dialogue_response with a valid entity_id must register the color
|
||||
## even if show_dialogue was not called first (defensive guard).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.append_dialogue_response("Voss", "I see.", 700)
|
||||
assert_bool(box._npc_entity_colors.has(700)).override_failure_message(
|
||||
"append_dialogue_response must register color for unknown entity ID (#573)"
|
||||
).is_true()
|
||||
@@ -596,3 +596,77 @@ func test_is_dialogue_active_true_after_show_dialogue() -> void:
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(box.is_dialogue_active()).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-020 (#558): Signal decoupling — dialogue_box emits signals instead of
|
||||
# directly mutating GameState or calling AudioManager.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_dialogue_state_changed_emits_true_on_show() -> void:
|
||||
## D-020: show_dialogue() must emit dialogue_state_changed(true).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(received.has(true)).override_failure_message(
|
||||
"dialogue_state_changed(true) must be emitted on show_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_dialogue_state_changed_emits_false_on_hide() -> void:
|
||||
## D-020: hide_dialogue() must emit dialogue_state_changed(false).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.dialogue_state_changed.connect(func(active): received.append(active))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
box.hide_dialogue()
|
||||
assert_bool(received.has(false)).override_failure_message(
|
||||
"dialogue_state_changed(false) must be emitted on hide_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_audio_dip_requested_emits_dialogue_on_show() -> void:
|
||||
## D-020: show_dialogue() must emit audio_dip_requested("dialogue").
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var received: Array = []
|
||||
box.audio_dip_requested.connect(func(profile): received.append(profile))
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(received.has("dialogue")).override_failure_message(
|
||||
"audio_dip_requested('dialogue') must be emitted on show_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_audio_dip_cleared_emits_on_hide() -> void:
|
||||
## D-020: hide_dialogue() must emit audio_dip_cleared.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var cleared := [false]
|
||||
box.audio_dip_cleared.connect(func(): cleared[0] = true)
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
box.hide_dialogue()
|
||||
assert_bool(cleared[0]).override_failure_message(
|
||||
"audio_dip_cleared must be emitted on hide_dialogue"
|
||||
).is_true()
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func test_no_direct_game_state_mutation() -> void:
|
||||
## D-020: dialogue_box must not directly mutate GameState.dialogue_active.
|
||||
## After show_dialogue, GameState.dialogue_active should remain unchanged
|
||||
## (only the coordinator updates it via signal handler).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
GameState.dialogue_active = false
|
||||
box.show_dialogue("NPC", "Speech.", _make_options(["Reply"]))
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"GameState.dialogue_active must NOT be mutated directly by dialogue_box"
|
||||
).is_false()
|
||||
box.queue_free()
|
||||
GameState.dialogue_active = false
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://b46mtkralmq14
|
||||
@@ -0,0 +1,143 @@
|
||||
## Sprint 20 #558: dialogue_box.gd decoupling tests.
|
||||
##
|
||||
## Verifies D-020 compliance: dialogue_box.gd emits signals instead of mutating
|
||||
## GameState or calling AudioManager directly. main.gd wires the signal handlers.
|
||||
##
|
||||
## D-030: fixture-based, server-free, no subprocess required.
|
||||
class_name TestDialogueSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _make_dialogue_box() -> Control:
|
||||
if not ResourceLoader.exists("res://ui/dialogue_box.tscn"):
|
||||
push_warning("TestDialogueSprint20: dialogue_box.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load("res://ui/dialogue_box.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# -- dialogue_state_changed signal -------------------------------------------
|
||||
|
||||
func test_show_dialogue_emits_dialogue_state_changed_true() -> void:
|
||||
## show_dialogue() must emit dialogue_state_changed(true) not mutate GameState directly.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var received: Variant = null
|
||||
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_that(received).override_failure_message(
|
||||
"show_dialogue() must emit dialogue_state_changed(true) (#558)"
|
||||
).is_equal(true)
|
||||
|
||||
|
||||
func test_show_dialogue_does_not_mutate_game_state_directly() -> void:
|
||||
## Without a connected handler, GameState.dialogue_active must stay false.
|
||||
## Proves dialogue_box.gd has zero direct GameState mutation (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
GameState.dialogue_active = false
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"dialogue_box must not mutate GameState.dialogue_active directly (D-020 #558)"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_hide_dialogue_emits_dialogue_state_changed_false() -> void:
|
||||
## hide_dialogue() must emit dialogue_state_changed(false).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var received: Variant = null
|
||||
box.dialogue_state_changed.connect(func(active: bool): received = active)
|
||||
box.hide_dialogue()
|
||||
assert_that(received).override_failure_message(
|
||||
"hide_dialogue() must emit dialogue_state_changed(false) (#558)"
|
||||
).is_equal(false)
|
||||
|
||||
|
||||
# -- audio_dip_requested / audio_dip_cleared signals -------------------------
|
||||
|
||||
func test_show_dialogue_emits_audio_dip_requested_dialogue() -> void:
|
||||
## show_dialogue() must emit audio_dip_requested("dialogue") not call AudioManager.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var received_profile: Variant = null
|
||||
box.audio_dip_requested.connect(func(profile: String): received_profile = profile)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_that(received_profile).override_failure_message(
|
||||
"show_dialogue() must emit audio_dip_requested('dialogue') (#558)"
|
||||
).is_equal("dialogue")
|
||||
|
||||
|
||||
func test_show_dialogue_does_not_call_audio_manager_directly() -> void:
|
||||
## Without a connected handler, AudioManager state must be unchanged by show_dialogue().
|
||||
## Verifies no direct AudioManager call in dialogue_box.gd (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var dip_before := AudioManager.get_active_dip()
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var dip_after := AudioManager.get_active_dip()
|
||||
assert_str(dip_after).override_failure_message(
|
||||
"dialogue_box must not call AudioManager.apply_dip() directly (D-020 #558)"
|
||||
).is_equal(dip_before)
|
||||
|
||||
|
||||
func test_hide_dialogue_emits_audio_dip_cleared() -> void:
|
||||
## Ending a conversation must emit audio_dip_cleared not call AudioManager directly.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
var cleared := false
|
||||
box.audio_dip_cleared.connect(func(): cleared = true)
|
||||
box.hide_dialogue()
|
||||
assert_bool(cleared).override_failure_message(
|
||||
"hide_dialogue() must emit audio_dip_cleared (#558)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_audio_dip_cleared_count_on_conversation_end() -> void:
|
||||
## Verify audio_dip_cleared fires when conversation ends.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
var cleared_count := 0
|
||||
box.audio_dip_cleared.connect(func(): cleared_count += 1)
|
||||
box.show_dialogue("NPC", "Speak.", [])
|
||||
box.hide_dialogue()
|
||||
assert_int(cleared_count).override_failure_message(
|
||||
"audio_dip_cleared must fire at least once when conversation ends (#558)"
|
||||
).is_greater_equal(1)
|
||||
|
||||
|
||||
# -- coordinator wiring verification -----------------------------------------
|
||||
|
||||
func test_signal_handler_wires_game_state() -> void:
|
||||
## Simulate main.gd: connect dialogue_state_changed to update GameState.dialogue_active.
|
||||
## Verifies the coordinator pattern works end-to-end (D-020 #558).
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
auto_free(box)
|
||||
box.dialogue_state_changed.connect(func(active: bool): GameState.dialogue_active = active)
|
||||
box.show_dialogue("NPC", "Hello.", [])
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"With handler wired, GameState.dialogue_active must be true after show_dialogue (#558)"
|
||||
).is_true()
|
||||
box.hide_dialogue()
|
||||
assert_bool(GameState.dialogue_active).override_failure_message(
|
||||
"With handler wired, GameState.dialogue_active must be false after hide_dialogue (#558)"
|
||||
).is_false()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bh8tiio43gveq
|
||||