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.
|
- **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 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/`.
|
- **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)
|
## Team workflow (mandatory)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Local Services
|
# 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`)
|
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ docs/
|
|||||||
workshops/ # Workshop briefs and outputs
|
workshops/ # Workshop briefs and outputs
|
||||||
db/
|
db/
|
||||||
schema.sql # Database schema
|
schema.sql # Database schema
|
||||||
connectors/ # Connector scripts for SQLite and Qdrant
|
tooling/
|
||||||
|
db/ # Connector scripts for SQLite, Qdrant, and audio
|
||||||
config.json # Endpoint configuration
|
config.json # Endpoint configuration
|
||||||
ticket # Ticket CLI
|
ticket # Ticket CLI
|
||||||
|
sprint # Sprint lifecycle CLI
|
||||||
sqlite_connector.py # SQLite mini MCP
|
sqlite_connector.py # SQLite mini MCP
|
||||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||||
|
audio_connector.py # Stable Audio Open connector
|
||||||
.claude/
|
.claude/
|
||||||
agents/ # Agent personality files
|
agents/ # Agent personality files
|
||||||
skills/ # Skill definitions
|
skills/ # Skill definitions
|
||||||
|
|||||||
@@ -25,20 +25,22 @@
|
|||||||
"Bash(git ls-tree *)",
|
"Bash(git ls-tree *)",
|
||||||
"Bash(git rev-parse --show-toplevel)",
|
"Bash(git rev-parse --show-toplevel)",
|
||||||
|
|
||||||
"Bash(db/connectors/ticket *)",
|
"Bash(tooling/db/ticket *)",
|
||||||
"Bash(db/connectors/sprint *)",
|
"Bash(tooling/db/sprint *)",
|
||||||
"Bash(db/connectors/sqlite-query *)",
|
"Bash(tooling/db/sqlite-query *)",
|
||||||
"Bash(db/connectors/sqlite-exec *)",
|
"Bash(tooling/db/sqlite-exec *)",
|
||||||
"Bash(db/connectors/qdrant-search *)",
|
"Bash(tooling/db/qdrant-search *)",
|
||||||
"Bash(db/connectors/qdrant-index *)",
|
"Bash(tooling/db/qdrant-index *)",
|
||||||
"Bash(db/connectors/qdrant-health)",
|
"Bash(tooling/db/qdrant-health)",
|
||||||
"Bash(db/connectors/qdrant-count)",
|
"Bash(tooling/db/qdrant-count)",
|
||||||
"Bash(db/connectors/sqlite-init)",
|
"Bash(tooling/db/sqlite-init)",
|
||||||
"Bash(db/connectors/decisions-sync)",
|
"Bash(tooling/db/decisions-sync)",
|
||||||
|
"Bash(tooling/db/decision *)",
|
||||||
|
|
||||||
"Bash(db/connectors/audio-generate *)",
|
"Bash(tooling/db/audio-generate *)",
|
||||||
"Bash(db/connectors/audio-health)",
|
"Bash(tooling/db/audio-health)",
|
||||||
"Bash(db/connectors/audio-post *)",
|
"Bash(tooling/db/audio-post *)",
|
||||||
|
"Bash(tooling/db/audio-batch *)",
|
||||||
|
|
||||||
"Bash(make *)",
|
"Bash(make *)",
|
||||||
"Bash(make)",
|
"Bash(make)",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ description: >
|
|||||||
# Audio Generation — The Settled Reach
|
# Audio Generation — The Settled Reach
|
||||||
|
|
||||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
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
|
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||||
@@ -25,21 +25,21 @@ workflow, and quality validation.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check API health
|
# Check API health
|
||||||
db/connectors/audio-health
|
tooling/db/audio-health
|
||||||
|
|
||||||
# Generate a single asset (WAV only)
|
# Generate a single asset (WAV only)
|
||||||
db/connectors/audio-generate "prompt text" \
|
tooling/db/audio-generate "prompt text" \
|
||||||
--duration 10 --steps 100 --cfg 7 \
|
--duration 10 --steps 100 --cfg 7 \
|
||||||
--output path/to/output.wav
|
--output path/to/output.wav
|
||||||
|
|
||||||
# Generate + post-process in one command (WAV → trim → normalize → OGG)
|
# 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 \
|
--duration 10 --steps 100 --cfg 7 \
|
||||||
--output path/to/gen/intermediate.wav \
|
--output path/to/gen/intermediate.wav \
|
||||||
--output-ogg client/assets/audio/final.ogg
|
--output-ogg client/assets/audio/final.ogg
|
||||||
|
|
||||||
# Batch-generate from a manifest (preferred for multiple assets)
|
# 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
|
### Parameters
|
||||||
@@ -138,16 +138,16 @@ AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full run
|
# 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
|
# 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
|
# 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
|
# 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
|
### 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.
|
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||||
3. Read `references/category-templates.md` for the matching template.
|
3. Read `references/category-templates.md` for the matching template.
|
||||||
4. Assemble the full prompt.
|
4. Assemble the full prompt.
|
||||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
5. Run `tooling/db/audio-health` to verify the API is up.
|
||||||
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
|
6. Run `tooling/db/audio-generate` with `--post` or `--output-ogg` to
|
||||||
generate and post-process in one step.
|
generate and post-process in one step.
|
||||||
7. Verify the output (file size, duration).
|
7. Verify the output (file size, duration).
|
||||||
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
|
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
|
```bash
|
||||||
# Full pipeline: trim → normalize → convert
|
# 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
|
# Individual steps
|
||||||
db/connectors/audio-post trim input.wav
|
tooling/db/audio-post trim input.wav
|
||||||
db/connectors/audio-post normalize input.wav --lufs -16
|
tooling/db/audio-post normalize input.wav --lufs -16
|
||||||
db/connectors/audio-post convert input.wav --output output.ogg
|
tooling/db/audio-post convert input.wav --output output.ogg
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Synthesis (Insert-Tech Sounds)
|
## 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:
|
investigation findings. Use the ticket CLI:
|
||||||
|
|
||||||
```bash
|
```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:
|
The description should include:
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ and workflows.
|
|||||||
|
|
||||||
For precise indexing of specific content:
|
For precise indexing of specific content:
|
||||||
```bash
|
```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
|
### Create collection
|
||||||
|
|
||||||
Initialize the Qdrant collection (run once during setup):
|
Initialize the Qdrant collection (run once during setup):
|
||||||
```bash
|
```bash
|
||||||
python3 db/connectors/qdrant_connector.py create-collection
|
python3 tooling/db/qdrant_connector.py create-collection
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bulk Indexing
|
## Bulk Indexing
|
||||||
@@ -35,7 +35,7 @@ python3 db/connectors/qdrant_connector.py create-collection
|
|||||||
Index all project documents at once:
|
Index all project documents at once:
|
||||||
```bash
|
```bash
|
||||||
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
|
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
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ Extract ticket IDs from `#NNN` patterns. For each ticket that is
|
|||||||
currently `in_progress`, update it to `review`:
|
currently `in_progress`, update it to `review`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket status <id> review
|
tooling/db/ticket status <id> review
|
||||||
```
|
```
|
||||||
|
|
||||||
Report which tickets were moved to review. Skip tickets that are
|
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:
|
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||||
2. Read source files directly from the branch 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
|
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||||
raw diff to reviewers — cleaner context, better reviews.
|
raw diff to reviewers — cleaner context, better reviews.
|
||||||
|
|
||||||
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
|
||||||
worktrees. Each team branch is checked out at:
|
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
|
**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:
|
`git show origin/<branch>:<path>` because:
|
||||||
- All agents have Read access (no Bash dependency)
|
- All agents have Read access (no Bash dependency)
|
||||||
- Files are always the actual branch checkout (no stale cache)
|
- Files are always the actual branch checkout (no stale cache)
|
||||||
- No risk of accidentally reading from main's working directory
|
- No risk of accidentally reading from main's working directory
|
||||||
|
|
||||||
When constructing reviewer prompts, tell agents to read files from the
|
When constructing reviewer prompts, tell agents to read files from the
|
||||||
worktree path. Example instruction for agents:
|
team directory. Example instruction for agents:
|
||||||
|
|
||||||
```
|
```
|
||||||
Read the changed files from the branch worktree. The branch is checked
|
Read the changed files from the team directory. The branch is checked
|
||||||
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
out at: /var/mnt/data/projects/settled-reach/<branch>/
|
||||||
|
|
||||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
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
|
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||||
worktree (they're identical to main, but using the worktree path keeps
|
directory (they're identical to main, but using the team directory path
|
||||||
agents grounded in the correct directory).
|
keeps agents grounded in the correct location).
|
||||||
|
|
||||||
### 4. Spawn reviewers in parallel
|
### 4. Spawn reviewers in parallel
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,24 @@
|
|||||||
|
|
||||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||||
|
|
||||||
**All agents read from worktree paths.** Each branch is checked out at:
|
**All reviewer agents read from team directories.** Each team branch is
|
||||||
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
|
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
|
Tell every reviewer agent to read source files from the team directory
|
||||||
Read tool. Include the worktree base path and a list of changed files in
|
using the Read tool. Include the directory path and a list of changed
|
||||||
every prompt. Do NOT rely on `git show` or paste file contents — agents
|
files in every prompt. Do NOT rely on `git show` or paste file contents —
|
||||||
can read directly from the worktree.
|
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`)
|
## Code reviews (`server`, `client`, `ci`)
|
||||||
|
|
||||||
**Hoshe (Code Quality)**
|
**Hoshe (Code Quality)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `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 Hoshe to read source files from the worktree, then review for:
|
Tell Hoshe to read source files from the team directory, then review for:
|
||||||
- Correctness and bug risks
|
- Correctness and bug risks
|
||||||
- Error handling gaps
|
- Error handling gaps
|
||||||
- Test coverage (are new features tested?)
|
- Test coverage (are new features tested?)
|
||||||
@@ -25,8 +29,8 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Tyre (Architecture)**
|
**Tyre (Architecture)**
|
||||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
- `subagent_type`: `tyre`, `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 Tyre to read the relevant `decisions/*.md` files from the worktree
|
Tell Tyre to read the relevant `decisions/*.md` files from the team directory
|
||||||
first, then review for:
|
first, then review for:
|
||||||
- Architectural consistency with project decisions
|
- Architectural consistency with project decisions
|
||||||
- API/interface design quality
|
- API/interface design quality
|
||||||
@@ -38,8 +42,8 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `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 Hoshe to read the changed files from the worktree, then review for:
|
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||||
- Formatting consistency (markdown, file naming, frontmatter)
|
- Formatting consistency (markdown, file naming, frontmatter)
|
||||||
- Broken references or links
|
- Broken references or links
|
||||||
- Spelling and grammar
|
- Spelling and grammar
|
||||||
@@ -48,9 +52,9 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Paula (Narrative Depth)**
|
**Paula (Narrative Depth)**
|
||||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
- `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
|
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
|
- Narrative quality and character voice consistency
|
||||||
- Whether dialogue and monologue feel authentic to the characters
|
- Whether dialogue and monologue feel authentic to the characters
|
||||||
- Consequences and stakes — do choices carry weight?
|
- Consequences and stakes — do choices carry weight?
|
||||||
@@ -59,9 +63,9 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Miri (World Consistency)**
|
**Miri (World Consistency)**
|
||||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
- `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
|
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?
|
- Lore accuracy — do facts match established setting?
|
||||||
- Internal consistency across files
|
- Internal consistency across files
|
||||||
- IP originality — nothing should read as a copy from another franchise
|
- IP originality — nothing should read as a copy from another franchise
|
||||||
@@ -72,8 +76,8 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `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 Hoshe to read the changed files from the worktree, then review for:
|
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||||
- File format and naming conventions
|
- File format and naming conventions
|
||||||
- Asset organization and directory structure
|
- Asset organization and directory structure
|
||||||
- Missing or broken references in scene/resource files
|
- Missing or broken references in scene/resource files
|
||||||
@@ -81,7 +85,7 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Araminta (Art Direction)**
|
**Araminta (Art Direction)**
|
||||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
- `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
|
Tell Araminta to read the style guide and relevant design docs from the
|
||||||
worktree first, then review for:
|
worktree first, then review for:
|
||||||
- Visual consistency with the established style guide
|
- Visual consistency with the established style guide
|
||||||
@@ -94,8 +98,8 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `subagent_type`: `hoshe`, `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 Hoshe to read the changed files from the worktree, then review for:
|
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||||
- File format and naming conventions
|
- File format and naming conventions
|
||||||
- Audio asset organization and directory structure
|
- Audio asset organization and directory structure
|
||||||
- Missing or broken references
|
- Missing or broken references
|
||||||
@@ -103,8 +107,8 @@ can read directly from the worktree.
|
|||||||
|
|
||||||
**Ozzie (Player Experience)**
|
**Ozzie (Player Experience)**
|
||||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
- `subagent_type`: `ozzie`, `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 Ozzie to read all files from the worktree using the Read tool, then
|
Tell Ozzie to read all files from the team directory using the Read tool, then
|
||||||
review for:
|
review for:
|
||||||
- Emotional impact — does the audio enhance the moment?
|
- Emotional impact — does the audio enhance the moment?
|
||||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ the user and a purpose-assembled agent panel.
|
|||||||
Get carry-overs, backlog candidates, and decision gaps in one shot:
|
Get carry-overs, backlog candidates, and decision gaps in one shot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/sprint prepare
|
tooling/db/sprint prepare
|
||||||
```
|
```
|
||||||
|
|
||||||
This auto-detects the next sprint number (max ID + 1), creates the sprint
|
This auto-detects the next sprint number (max ID + 1), creates the sprint
|
||||||
@@ -103,10 +103,10 @@ record in `planning` status if needed, and outputs:
|
|||||||
For critical epics, check their children for granular candidates:
|
For critical epics, check their children for granular candidates:
|
||||||
|
|
||||||
```bash
|
```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
|
### 3. Read existing code state
|
||||||
|
|
||||||
@@ -154,13 +154,12 @@ Create `docs/sprints/sprint-N/` and write one file per team.
|
|||||||
Read the template at `references/briefing-template.md` in this skill directory
|
Read the template at `references/briefing-template.md` in this skill directory
|
||||||
for the exact file structure.
|
for the exact file structure.
|
||||||
|
|
||||||
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
|
**IMPORTANT — relative paths only:** Each team works in its own directory
|
||||||
Each team branch is checked out in its own worktree, and each worktree
|
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||||
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
paths in briefings must be relative to the working directory. Example:
|
||||||
paths in briefings must be relative to the worktree/git root. Example:
|
|
||||||
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
|
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
|
||||||
paths that navigate outside the git root (`../sibling-worktree/...`).
|
paths that navigate outside (`../sibling-dir/...`).
|
||||||
Agents must stay within the git root they are running in.
|
Agents must stay within their team's working directory.
|
||||||
|
|
||||||
Key requirements per file:
|
Key requirements per file:
|
||||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||||
@@ -184,16 +183,29 @@ Update it with the theme and goal, then assign tickets:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Update the sprint with theme and goal
|
# 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
|
# 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
|
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:
|
Output:
|
||||||
- Sprint number, theme, and goal
|
- Sprint number, theme, and goal
|
||||||
@@ -201,3 +213,4 @@ Output:
|
|||||||
- Carry-over count
|
- Carry-over count
|
||||||
- Open questions that need early resolution
|
- Open questions that need early resolution
|
||||||
- Files written
|
- 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 — |
|
| #ID | Title | #dependency or — |
|
||||||
|
|
||||||
Use `db/connectors/ticket show <id>` for full details.
|
Use `tooling/db/ticket show <id>` for full details.
|
||||||
|
|
||||||
## Key Decisions
|
## 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:
|
and do the next right thing. Query the database to determine the state:
|
||||||
|
|
||||||
```bash
|
```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**:
|
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:
|
First, check whether the sprint's work is actually done:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/sprint status
|
tooling/db/sprint status
|
||||||
```
|
```
|
||||||
|
|
||||||
This shows ticket counts by status (done, in_progress, backlog).
|
This shows ticket counts by status (done, in_progress, backlog).
|
||||||
@@ -81,7 +81,7 @@ explicitly chooses to close.
|
|||||||
#### A1. Close the active sprint
|
#### A1. Close the active sprint
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/sprint stop
|
tooling/db/sprint stop
|
||||||
```
|
```
|
||||||
|
|
||||||
This marks the active sprint as completed and lists carry-over candidates.
|
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:
|
2. Check the ticket count:
|
||||||
```bash
|
```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
|
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:
|
If everything looks ready, activate the sprint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/sprint start
|
tooling/db/sprint start
|
||||||
```
|
```
|
||||||
|
|
||||||
Then report:
|
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:
|
Run the sprint CLI to get the full context dump in one shot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/sprint start-work
|
tooling/db/sprint start-work
|
||||||
```
|
```
|
||||||
|
|
||||||
This auto-detects the active sprint and current team from the branch.
|
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:
|
For tickets that need more detail than the `start-work` summary provides:
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket show <id>
|
tooling/db/ticket show <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Read key decisions
|
### 6. Read key decisions
|
||||||
@@ -218,7 +218,7 @@ Mark all actionable (unblocked, non-done) tickets in the sprint as
|
|||||||
`in_progress`:
|
`in_progress`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket status <id> in_progress
|
tooling/db/ticket status <id> in_progress
|
||||||
```
|
```
|
||||||
|
|
||||||
Then output a summary:
|
Then output a summary:
|
||||||
@@ -290,14 +290,19 @@ Task(
|
|||||||
|
|
||||||
RULES (NON-NEGOTIABLE):
|
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,
|
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||||
checkout, branch, stash, tag, etc.). All git operations are
|
checkout, branch, stash, tag, etc.). All git operations are
|
||||||
handled by the team lead. No exceptions.
|
handled by the team lead. No exceptions.
|
||||||
|
|
||||||
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||||
the exact command with no wrappers or chaining. Examples:
|
the exact command with no wrappers or chaining. Examples:
|
||||||
db/connectors/ticket show 528
|
tooling/db/ticket show 528
|
||||||
db/connectors/ticket list --sprint {N}
|
tooling/db/ticket list --sprint {N}
|
||||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||||
add cleanup commands. Just the bare command.
|
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
|
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||||
on your own.
|
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}",
|
description: "Sprint {N} {team}: {name}",
|
||||||
run_in_background: true
|
run_in_background: true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ the workflow.
|
|||||||
Run these two commands in parallel:
|
Run these two commands in parallel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
db/connectors/sprint sweep
|
tooling/db/sprint sweep
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -17,60 +17,60 @@ section. This skill covers the full command reference.
|
|||||||
|
|
||||||
### List tickets (full flags)
|
### List tickets (full flags)
|
||||||
```bash
|
```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
|
### Create ticket
|
||||||
```bash
|
```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`
|
Types: `initiative`, `epic`, `story`, `task`, `bug`
|
||||||
Priorities: `critical`, `high`, `medium`, `low`
|
Priorities: `critical`, `high`, `medium`, `low`
|
||||||
|
|
||||||
### Update status
|
### Update status
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket status <id> <new_status>
|
tooling/db/ticket status <id> <new_status>
|
||||||
db/connectors/ticket done <id> [<id> ...]
|
tooling/db/ticket done <id> [<id> ...]
|
||||||
```
|
```
|
||||||
Statuses: `backlog`, `ready`, `in_progress`, `review`, `done`, `cancelled`
|
Statuses: `backlog`, `ready`, `in_progress`, `review`, `done`, `cancelled`
|
||||||
|
|
||||||
### Assignment
|
### Assignment
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket assign <id> <agent>
|
tooling/db/ticket assign <id> <agent>
|
||||||
db/connectors/ticket unassign <id>
|
tooling/db/ticket unassign <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Team assignment
|
### Team assignment
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket team <id> <teams>
|
tooling/db/ticket team <id> <teams>
|
||||||
```
|
```
|
||||||
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
|
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
|
||||||
|
|
||||||
### Sprint management
|
### Sprint management
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket sprint [--active]
|
tooling/db/ticket sprint [--active]
|
||||||
db/connectors/ticket sprint assign <id> <sprint_id>
|
tooling/db/ticket sprint assign <id> <sprint_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
For sprint-scoped operations (status overview, context dumps, lifecycle),
|
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
|
### Dependencies
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket deps <id>
|
tooling/db/ticket deps <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Search and browse
|
### Search and browse
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket search <keyword>
|
tooling/db/ticket search <keyword>
|
||||||
db/connectors/ticket epics [--status S]
|
tooling/db/ticket epics [--status S]
|
||||||
db/connectors/ticket children <id>
|
tooling/db/ticket children <id>
|
||||||
db/connectors/ticket count [--status S]
|
tooling/db/ticket count [--status S]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Batch show
|
### Batch show
|
||||||
```bash
|
```bash
|
||||||
db/connectors/ticket show --brief <id> [<id>...]
|
tooling/db/ticket show --brief <id> [<id>...]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|||||||
@@ -6,6 +6,71 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v0.1.20] — 2026-02-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -129,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)
|
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||||
|
|
||||||
### Changed
|
### 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/`
|
- 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-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
- `sprint 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
|
tooling/ # Build tools, scripts, asset pipelines
|
||||||
tests/ # Integration and end-to-end tests
|
tests/ # Integration and end-to-end tests
|
||||||
docs/ # Architecture, design, briefings, sprints, workshops
|
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
|
.claude/ # Agents, skills, rules
|
||||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
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
|
## 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 current working directory (e.g. `server/src/bridge/types.rs`).
|
||||||
- All file paths are relative to the worktree root (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 navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
|
||||||
- **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.
|
- 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
|
### Database
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
|||||||
|
|
||||||
### Before starting work
|
### Before starting work
|
||||||
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
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
|
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||||
|
|
||||||
@@ -52,19 +53,26 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
|||||||
|
|
||||||
| Tool | Command | Full reference |
|
| Tool | Command | Full reference |
|
||||||
|------|---------|----------------|
|
|------|---------|----------------|
|
||||||
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||||
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||||
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
|
||||||
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
|
||||||
| Decisions | `db/connectors/decision next`, `claim`, `check-dupes` | — |
|
| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — |
|
||||||
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
|
||||||
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/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
|
### File conventions
|
||||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||||
- **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.
|
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||||
- Briefings: one per agent, updated after decision-producing rounds
|
- Briefings: one per agent, updated after decision-producing rounds
|
||||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
- Tickets: managed via `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 \
|
fixtures-client golden-diff golden-update \
|
||||||
checklist-validate checklist-generate \
|
checklist-validate checklist-generate \
|
||||||
perf-baseline debug-schedule \
|
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 ---
|
# --- Configuration ---
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ help:
|
|||||||
@echo " make checklist-generate Validate checklists + print condition summary"
|
@echo " make checklist-generate Validate checklists + print condition summary"
|
||||||
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
||||||
@echo ""
|
@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 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-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||||
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
||||||
@@ -284,16 +290,16 @@ db-install:
|
|||||||
# --- Decisions ---
|
# --- Decisions ---
|
||||||
|
|
||||||
decisions-sync:
|
decisions-sync:
|
||||||
@db/connectors/decisions-sync
|
@tooling/db/decisions-sync
|
||||||
|
|
||||||
decisions-coverage:
|
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:
|
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:
|
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 ---
|
# --- Content Validation ---
|
||||||
|
|
||||||
@@ -318,6 +324,20 @@ debug-schedule:
|
|||||||
@echo "Dumping bevy_ecs schedule graph..."
|
@echo "Dumping bevy_ecs schedule graph..."
|
||||||
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
|
@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:
|
content-ron:
|
||||||
cd tooling/content-converter && cargo build --release
|
cd tooling/content-converter && cargo build --release
|
||||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
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."
|
load_failed: "Load failed."
|
||||||
connection_lost: "Signal interrupted."
|
connection_lost: "Signal interrupted."
|
||||||
connection_restored: "Signal restored."
|
connection_restored: "Signal restored."
|
||||||
|
loading: "Resuming..."
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# KNOWLEDGE PANEL LABELS
|
# KNOWLEDGE PANEL LABELS
|
||||||
@@ -183,6 +184,9 @@ menu:
|
|||||||
confirm_quit: "Unsaved progress will be lost."
|
confirm_quit: "Unsaved progress will be lost."
|
||||||
confirm_yes: "Yes"
|
confirm_yes: "Yes"
|
||||||
confirm_no: "No"
|
confirm_no: "No"
|
||||||
|
load_game_browse: "LOAD GAME"
|
||||||
|
load_game_back: "BACK"
|
||||||
|
load_game_empty: "No saves found."
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
audio_volume: "Volume"
|
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/main.gd" id="1_main"]
|
||||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||||
@@ -21,10 +21,12 @@
|
|||||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
[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/bug_report_dialog.tscn" id="19_bugreport"]
|
||||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
[ext_resource type="Script" path="res://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/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/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/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"]
|
[node name="Game" type="Node2D"]
|
||||||
script = ExtResource("1_main")
|
script = ExtResource("1_main")
|
||||||
@@ -190,3 +192,9 @@ layer = 30
|
|||||||
|
|
||||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
[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_font_sizes/font_size = 15
|
||||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
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"]
|
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
text = "QUIT"
|
text = "QUIT"
|
||||||
theme_override_font_sizes/font_size = 15
|
theme_override_font_sizes/font_size = 15
|
||||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
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.
|
# Used by fog shader to distinguish visual treatment per tile.
|
||||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
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 VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||||
|
|
||||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
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)
|
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 map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
|
||||||
var visibility_texture: ImageTexture
|
var visibility_texture: ImageTexture
|
||||||
var exploration_texture: ImageTexture
|
var exploration_texture: ImageTexture
|
||||||
@@ -25,16 +41,34 @@ var _exp_bytes: PackedByteArray
|
|||||||
var _vis_image: Image
|
var _vis_image: Image
|
||||||
var _exp_image: Image
|
var _exp_image: Image
|
||||||
var _tint_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 _width: int = 1
|
||||||
var _height: int = 1
|
var _height: int = 1
|
||||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
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:
|
func _ready() -> void:
|
||||||
_resize(Rect2i(0, 0, 64, 64))
|
_resize(Rect2i(0, 0, 64, 64))
|
||||||
|
|
||||||
|
|
||||||
func _resize(bounds: Rect2i) -> void:
|
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
|
map_bounds = bounds
|
||||||
_width = maxi(bounds.size.x, 1)
|
_width = maxi(bounds.size.x, 1)
|
||||||
_height = maxi(bounds.size.y, 1)
|
_height = maxi(bounds.size.y, 1)
|
||||||
@@ -49,41 +83,92 @@ func _resize(bounds: Rect2i) -> void:
|
|||||||
_exp_bytes = PackedByteArray()
|
_exp_bytes = PackedByteArray()
|
||||||
_exp_bytes.resize(sz)
|
_exp_bytes.resize(sz)
|
||||||
_exp_bytes.fill(EXP_UNEXPLORED)
|
_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)
|
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||||
exploration_texture = ImageTexture.create_from_image(_exp_image)
|
exploration_texture = ImageTexture.create_from_image(_exp_image)
|
||||||
|
|
||||||
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
|
# Zone tint — 3 bytes per pixel (RGB), default neutral dark
|
||||||
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
|
var tint_sz := sz * 3
|
||||||
_tint_image.fill(Color(0.05, 0.05, 0.08))
|
_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)
|
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
|
||||||
|
|
||||||
_prev_visible.clear()
|
_prev_visible.clear()
|
||||||
|
|
||||||
|
|
||||||
func update_from_state() -> void:
|
func update_from_state() -> void:
|
||||||
# Resize if map bounds changed
|
# Grow bounds to include newly visible tiles — never shrink, so explored
|
||||||
var tiles := GameState.visible_tiles
|
# tiles behind the player stay in the texture and render as deep fog
|
||||||
if tiles.size() > 0:
|
# instead of black. Exploration data is preserved across resizes.
|
||||||
var new_bounds := _compute_bounds(tiles)
|
# 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:
|
if new_bounds != map_bounds:
|
||||||
_resize(new_bounds)
|
_resize(new_bounds)
|
||||||
|
|
||||||
var ox: int = map_bounds.position.x
|
var ox: int = map_bounds.position.x
|
||||||
var oy: int = map_bounds.position.y
|
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
|
# 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)
|
_vis_bytes.fill(VIS_HIDDEN)
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
var px: int = pos.x - ox
|
var px: int = pos.x - ox
|
||||||
var py: int = pos.y - oy
|
var py: int = pos.y - oy
|
||||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||||
continue
|
continue
|
||||||
var sector: String = sectors.get(pos, "Forward")
|
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||||
_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL
|
# #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)
|
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||||
visibility_texture.update(_vis_image)
|
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)
|
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||||
exploration_texture.update(_exp_image)
|
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
|
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
|
||||||
_prev_visible = positions.duplicate()
|
_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_x := 999999
|
||||||
var min_y := 999999
|
var min_y := 999999
|
||||||
var max_x := -999999
|
var max_x := -999999
|
||||||
var max_y := -999999
|
var max_y := -999999
|
||||||
for tile in tiles:
|
for pos in positions:
|
||||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
min_x = mini(min_x, pos.x)
|
||||||
continue
|
min_y = mini(min_y, pos.y)
|
||||||
min_x = mini(min_x, int(tile.x))
|
max_x = maxi(max_x, pos.x)
|
||||||
min_y = mini(min_y, int(tile.y))
|
max_y = maxi(max_y, pos.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
|
|
||||||
if min_x > max_x:
|
if min_x > max_x:
|
||||||
return Rect2i(0, 0, 1, 1)
|
return map_bounds
|
||||||
# Margin for fog gradient bleed at edges
|
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
|
||||||
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
|
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 player_position: Vector2 = Vector2.ZERO
|
||||||
var visible_entities: Array = []
|
var visible_entities: Array = []
|
||||||
var visible_tiles: 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)
|
# v2 fields (D-015, D-031)
|
||||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
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.
|
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||||
var insert_active: bool = true
|
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.
|
# #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).
|
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||||
var rng_seed: Variant = null
|
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.
|
# One-shot: consumed by main.gd after display, then set back to null.
|
||||||
var save_result: Variant = 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)
|
# v7 fields (#431, D-059/D-060)
|
||||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||||
|
|
||||||
@@ -300,6 +317,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
else:
|
else:
|
||||||
save_result = null
|
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.
|
# 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).
|
# 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:
|
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||||
@@ -325,17 +348,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||||
|
|
||||||
# v2: visible_tiles with visibility sectors
|
# v2: visible_tiles with visibility sectors
|
||||||
# Derives visible_positions when not explicitly provided (real server mode)
|
# Derives visible_positions when not explicitly provided (real server mode).
|
||||||
|
# #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:
|
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||||
visibility_sectors.clear()
|
visibility_sectors.clear()
|
||||||
var has_explicit_positions := snapshot.has("visible_positions")
|
var has_explicit_positions := snapshot.has("visible_positions")
|
||||||
if not has_explicit_positions:
|
if not has_explicit_positions:
|
||||||
visible_positions.clear()
|
visible_positions.clear()
|
||||||
|
boundary_positions.clear()
|
||||||
for vtile in snapshot.visible_tiles:
|
for vtile in snapshot.visible_tiles:
|
||||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||||
continue
|
continue
|
||||||
var pos := Vector2i(vtile.x, vtile.y)
|
var pos := Vector2i(vtile.x, vtile.y)
|
||||||
|
var vis_sector: String = vtile.get("visibility", "")
|
||||||
if vtile.has("visibility"):
|
if vtile.has("visibility"):
|
||||||
visibility_sectors[pos] = vtile.visibility
|
visibility_sectors[pos] = vis_sector
|
||||||
if not has_explicit_positions:
|
# #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
|
visible_positions[pos] = true
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ enum Action {
|
|||||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame 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] = []
|
var input_queue: Array[Dictionary] = []
|
||||||
|
|||||||
@@ -31,12 +31,27 @@ func new_game() -> String:
|
|||||||
save_path, error_string(err)])
|
save_path, error_string(err)])
|
||||||
return ""
|
return ""
|
||||||
GameState.current_game_id = game_id
|
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
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
## Resume an existing game session by setting the active 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:
|
func resume_game(game_id: String) -> void:
|
||||||
GameState.current_game_id = game_id
|
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).
|
## 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
|
_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:
|
func _find_newest_save(dir_path: String) -> String:
|
||||||
var dir := DirAccess.open(dir_path)
|
var dir := DirAccess.open(dir_path)
|
||||||
if dir == null:
|
if dir == null:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b357ok64jc8vp
|
||||||
@@ -231,6 +231,26 @@ func _process(delta: float) -> void:
|
|||||||
_set_state(ConnectionState.ERROR)
|
_set_state(ConnectionState.ERROR)
|
||||||
return
|
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)
|
handshake_complete.emit(server_version)
|
||||||
_set_state(ConnectionState.CONNECTED)
|
_set_state(ConnectionState.CONNECTED)
|
||||||
return
|
return
|
||||||
@@ -396,6 +416,8 @@ static func action_enum_to_wire(action: int) -> String:
|
|||||||
return "SaveGame" # #554: F5 quicksave (D-085)
|
return "SaveGame" # #554: F5 quicksave (D-085)
|
||||||
InputMapper.Action.LOAD_GAME:
|
InputMapper.Action.LOAD_GAME:
|
||||||
return "LoadGame" # #554: F6 quickload (D-085)
|
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)
|
push_warning("SimBridge: unknown action enum %s" % action)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ extends Node2D
|
|||||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||||
|
@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_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||||
@@ -49,6 +51,17 @@ func _ready() -> void:
|
|||||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||||
SimBridge.connect_to_sim()
|
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.
|
# Camera anchor: snap to player position before the first frame renders.
|
||||||
# In test mode poll_snapshot() returns synchronously — position is set
|
# In test mode poll_snapshot() returns synchronously — position is set
|
||||||
# immediately. In live mode the snapshot isn't available yet — _process
|
# immediately. In live mode the snapshot isn't available yet — _process
|
||||||
@@ -110,6 +123,16 @@ func _ready() -> void:
|
|||||||
_router.register("conversation_ended", _consume_conversation_ended)
|
_router.register("conversation_ended", _consume_conversation_ended)
|
||||||
_router.register("dialogue_response", _consume_dialogue_response)
|
_router.register("dialogue_response", _consume_dialogue_response)
|
||||||
_router.register("save_result", _consume_save_result)
|
_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:
|
func _process(delta: float) -> void:
|
||||||
@@ -161,6 +184,15 @@ func _process(delta: float) -> void:
|
|||||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||||
_toggle_journal()
|
_toggle_journal()
|
||||||
continue
|
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
|
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||||
if input.action == InputMapper.Action.OPEN_MENU:
|
if input.action == InputMapper.Action.OPEN_MENU:
|
||||||
if settings_dialog:
|
if settings_dialog:
|
||||||
@@ -330,7 +362,8 @@ func _consume_dialogue() -> void:
|
|||||||
dialogue_box.show_dialogue(
|
dialogue_box.show_dialogue(
|
||||||
dlg.get("npc_name", ""),
|
dlg.get("npc_name", ""),
|
||||||
dlg.get("speech", ""),
|
dlg.get("speech", ""),
|
||||||
dlg.get("options", [])
|
dlg.get("options", []),
|
||||||
|
_last_dialogue_npc_id
|
||||||
)
|
)
|
||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
|
|
||||||
@@ -360,20 +393,25 @@ func _consume_dialogue_response() -> void:
|
|||||||
if GameState.dialogue_response == null or not dialogue_box:
|
if GameState.dialogue_response == null or not dialogue_box:
|
||||||
return
|
return
|
||||||
var dr: Dictionary = GameState.dialogue_response
|
var dr: Dictionary = GameState.dialogue_response
|
||||||
|
# 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_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_color_index: int = dr.get("speaker_color_index", -1)
|
||||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
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.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
|
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:
|
func _consume_save_result() -> void:
|
||||||
if GameState.save_result == null:
|
if GameState.save_result == null:
|
||||||
return
|
return
|
||||||
var result: Dictionary = GameState.save_result
|
var result: Dictionary = GameState.save_result
|
||||||
GameState.save_result = null # consume once
|
GameState.save_result = null # consume once
|
||||||
|
# #257: Dismiss loading screen regardless of success/failure
|
||||||
|
if loading_screen:
|
||||||
|
loading_screen.hide_loading()
|
||||||
var msg: String
|
var msg: String
|
||||||
if result.get("success", false):
|
if result.get("success", false):
|
||||||
if result.get("kind", "") == "save":
|
if result.get("kind", "") == "save":
|
||||||
@@ -389,6 +427,14 @@ func _consume_save_result() -> void:
|
|||||||
monologue_display.show_notification(msg)
|
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
|
# D-061: Handle dialogue option selection → send to server
|
||||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||||
SimBridge.send_input({
|
SimBridge.send_input({
|
||||||
@@ -462,6 +508,32 @@ func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_stat
|
|||||||
gauntlet_hud.finalize()
|
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).
|
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
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.
|
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||||
## Reject snapshots where version != this value.
|
## Reject snapshots where version != this value.
|
||||||
const PROTOCOL_VERSION: int = 15
|
const PROTOCOL_VERSION: int = 18
|
||||||
|
|
||||||
|
|
||||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||||
@@ -244,6 +244,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"error": raw_save.get("error"),
|
"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).
|
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||||
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
# 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
|
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||||
@@ -320,6 +331,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"examine_result": examine_result,
|
"examine_result": examine_result,
|
||||||
"player_knowledge": player_knowledge,
|
"player_knowledge": player_knowledge,
|
||||||
"save_result": save_result,
|
"save_result": save_result,
|
||||||
|
"debug_response": debug_response,
|
||||||
"stationary_ticks": stationary_ticks,
|
"stationary_ticks": stationary_ticks,
|
||||||
"zone_id": zone_id,
|
"zone_id": zone_id,
|
||||||
}
|
}
|
||||||
@@ -427,6 +439,18 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
|||||||
|
|
||||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
# -- 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.
|
## Encode a PlayerInput to MessagePack bytes.
|
||||||
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
## "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.
|
## Reads textures from FogState autoload, positions rect to cover viewport.
|
||||||
## Architecture: docs/architecture/fog-shader-spec.md
|
## Architecture: docs/architecture/fog-shader-spec.md
|
||||||
|
|
||||||
|
signal fog_noise_ready
|
||||||
|
var _noise_ready: bool = false
|
||||||
|
|
||||||
var _fog_rect: ColorRect
|
var _fog_rect: ColorRect
|
||||||
var _shader_mat: ShaderMaterial
|
var _shader_mat: ShaderMaterial
|
||||||
|
|
||||||
@@ -36,10 +39,11 @@ func _ready() -> void:
|
|||||||
noise_tex.width = 256
|
noise_tex.width = 256
|
||||||
noise_tex.height = 256
|
noise_tex.height = 256
|
||||||
noise_tex.seamless = true
|
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("noise_tex", noise_tex)
|
||||||
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
|
_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:
|
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("rect_sz", _fog_rect.size)
|
||||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
_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("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
|
tile_set = ts
|
||||||
|
|
||||||
# Update tiles from snapshot data
|
# Update tiles from snapshot data
|
||||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)}
|
||||||
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
# 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).
|
# 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
|
# This node only renders floor-level 0. Higher floor levels will be handled
|
||||||
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
|
# 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:
|
func update_tiles(tiles: Array) -> void:
|
||||||
if not _initialized:
|
if not _initialized:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://drppri4b46v80
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cix55xks85vl8
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
shader_type canvas_item;
|
shader_type canvas_item;
|
||||||
|
|
||||||
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
|
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
|
||||||
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
|
// State 1: Clear (forward cone) — transparent, soft Gaussian gradient edge (3-4 tile radius)
|
||||||
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
|
// State 2: Explored (out of cone) — light fog overlay, alpha 0.25-0.35, zone temperature tint,
|
||||||
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
|
// 8-10s Perlin breathe. Art and information preserved, just "not fresh" (D-015).
|
||||||
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
|
// State 3: Unexplored — solid near-black #12141a
|
||||||
// Layer 5: Unexplored, no maps — 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 visibility_tex : filter_linear, repeat_disable;
|
||||||
uniform sampler2D exploration_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 sampler2D noise_tex : filter_linear, repeat_enable;
|
||||||
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
|
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
|
||||||
uniform vec2 rect_sz; // World-space size 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 vec2 map_size; // map_bounds.size (tiles)
|
||||||
uniform float tile_size; // Pixels per sim tile
|
uniform float tile_size; // Pixels per sim tile
|
||||||
uniform float time; // Seconds since start
|
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 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)
|
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
|
||||||
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
|
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
|
||||||
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
|
float sample_visibility(vec2 uv) {
|
||||||
const float PERIPHERAL_LOW = 0.55; // Below this: transition to deep/unexplored
|
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() {
|
void fragment() {
|
||||||
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
|
|
||||||
vec2 world_px = rect_pos + UV * rect_sz;
|
vec2 world_px = rect_pos + UV * rect_sz;
|
||||||
vec2 tile = world_px / tile_size;
|
vec2 tile = world_px / tile_size;
|
||||||
|
|
||||||
// Map tile coordinate to texture UV
|
|
||||||
vec2 tex_uv = (tile - map_offset) / map_size;
|
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) {
|
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);
|
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) {
|
// Debug mode: render raw exploration texture (bypass fog rendering).
|
||||||
// In or near vision cone
|
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
|
||||||
if (vis > CLEAR_THRESHOLD) {
|
} else if (debug_exploration) {
|
||||||
// Layer 1: Clear — soft edge gradient
|
float explored_dbg = texture(exploration_tex, tex_uv).r;
|
||||||
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
|
if (explored_dbg > 0.9) {
|
||||||
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
|
COLOR = vec4(0.0, explored_dbg, 0.0, 0.8); // Green: currently visible
|
||||||
} else {
|
} else if (explored_dbg > 0.1) {
|
||||||
// Layer 2: Light fog (peripheral + forward edge)
|
COLOR = vec4(0.0, 0.0, explored_dbg * 2.0, 0.8); // Blue: explored
|
||||||
// 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);
|
|
||||||
} else {
|
} 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);
|
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) ----------------------------------------
|
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
|
||||||
# Timer lifecycle, personal bests, room change, visibility.
|
# 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:
|
func test_format_time_zero() -> void:
|
||||||
# Static utility: 0 seconds → "00:00"
|
# Static utility: 0 seconds → "00:00"
|
||||||
@@ -476,7 +476,7 @@ func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
|
|||||||
|
|
||||||
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
||||||
# Pause/unpause lifecycle, wire guard, text render, state machine.
|
# 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:
|
func test_bug_report_sends_pause_on_open() -> void:
|
||||||
# start_capture() must send Pause to the server.
|
# 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:
|
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()
|
var fog = _get_fog_state()
|
||||||
if fog == null:
|
if fog == null:
|
||||||
return
|
return
|
||||||
@@ -65,8 +67,8 @@ func test_fog_visibility_peripheral_tile() -> void:
|
|||||||
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
|
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
|
||||||
fog.update_from_state()
|
fog.update_from_state()
|
||||||
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
|
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
|
||||||
"Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL
|
"All visible tiles write VIS_FORWARD after forward-only simplification"
|
||||||
).is_equal(FogState.VIS_PERIPHERAL)
|
).is_equal(FogState.VIS_FORWARD)
|
||||||
GameState.visible_positions.clear()
|
GameState.visible_positions.clear()
|
||||||
GameState.visibility_sectors.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,
|
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
|
||||||
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
||||||
renderer.update_entities(entity)
|
renderer.update_entities(entity)
|
||||||
var node = renderer.entity_nodes[70] as ColorRect
|
var node = renderer.entity_nodes[70] as Sprite2D
|
||||||
assert_that(node.color).override_failure_message(
|
assert_that(node.self_modulate).override_failure_message(
|
||||||
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
||||||
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||||
renderer.queue_free()
|
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,
|
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
|
||||||
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
||||||
renderer.update_entities(entity)
|
renderer.update_entities(entity)
|
||||||
var node = renderer.entity_nodes[80] as ColorRect
|
var node = renderer.entity_nodes[80] as Sprite2D
|
||||||
assert_that(node.color).override_failure_message(
|
assert_that(node.self_modulate).override_failure_message(
|
||||||
"Player color must be constant regardless of visibility sector"
|
"Player color must be constant regardless of visibility sector"
|
||||||
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||||
# Alpha should still be dimmed for Peripheral
|
# Alpha should still be dimmed for Peripheral
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ extends GdUnitTestSuite
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
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:
|
func _make_overlay() -> Control:
|
||||||
@@ -51,7 +51,7 @@ func after_test() -> void:
|
|||||||
|
|
||||||
func test_debug_overlay_script_exists() -> void:
|
func test_debug_overlay_script_exists() -> void:
|
||||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
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()
|
).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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b46mtkralmq14
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bh8tiio43gveq
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178)
|
||||||
|
##
|
||||||
|
## Test-first stubs for the client-side surface of the world_seed feature.
|
||||||
|
## These tests will warn-and-skip until the implementation lands (Tyre, #175).
|
||||||
|
##
|
||||||
|
## Client-side acceptance criteria (#175):
|
||||||
|
## - GameState carries a world_seed field (stores the seed for this session)
|
||||||
|
## - SessionManager.new_game() generates and stores a world_seed
|
||||||
|
## - The IPC startup payload carries world_seed so the server can seed SimRng
|
||||||
|
##
|
||||||
|
## Server-side acceptance criteria (#178) are in:
|
||||||
|
## - server/src/content/entanglement.rs (Rust unit tests)
|
||||||
|
##
|
||||||
|
## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim)
|
||||||
|
## Tickets: #175, #178
|
||||||
|
class_name TestEntanglementSprint22
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
# -- Client-side: GameState.world_seed field (#175) ---------------------------
|
||||||
|
|
||||||
|
func test_game_state_has_world_seed_field() -> void:
|
||||||
|
# #175 client-side: GameState must store the world_seed for this session.
|
||||||
|
# The seed is set by SessionManager.new_game() and read by SimBridge to
|
||||||
|
# carry it in the session startup IPC message.
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)")
|
||||||
|
return
|
||||||
|
# Field exists — verify it is numeric (int or null are both acceptable initial states)
|
||||||
|
var seed_val = GameState.get("world_seed")
|
||||||
|
assert_bool(seed_val == null or seed_val is int).override_failure_message(
|
||||||
|
"GameState.world_seed must be int or null"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_game_state_world_seed_can_be_set_and_read() -> void:
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)")
|
||||||
|
return
|
||||||
|
var orig = GameState.get("world_seed")
|
||||||
|
GameState.world_seed = 0xDEADBEEF
|
||||||
|
assert_int(GameState.world_seed).is_equal(0xDEADBEEF)
|
||||||
|
# Restore
|
||||||
|
GameState.world_seed = orig
|
||||||
|
|
||||||
|
|
||||||
|
func test_game_state_world_seed_default_is_null_or_zero() -> void:
|
||||||
|
# Before a session starts, world_seed should be null (no session) or 0 (unset).
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped")
|
||||||
|
return
|
||||||
|
var seed_val = GameState.get("world_seed")
|
||||||
|
assert_bool(seed_val == null or seed_val == 0).override_failure_message(
|
||||||
|
"GameState.world_seed should be null or 0 before any session starts"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Client-side: SessionManager seed generation (#175) -----------------------
|
||||||
|
|
||||||
|
func test_session_manager_exists() -> void:
|
||||||
|
var sm = get_node_or_null("/root/SessionManager")
|
||||||
|
if sm == null:
|
||||||
|
push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped")
|
||||||
|
return
|
||||||
|
assert_that(sm).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_session_manager_new_game_generates_world_seed() -> void:
|
||||||
|
# #175: new_game() must generate and store world_seed in GameState.
|
||||||
|
# The seed is a non-zero u64 that will be sent to the server on startup.
|
||||||
|
var sm = get_node_or_null("/root/SessionManager")
|
||||||
|
if sm == null:
|
||||||
|
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||||
|
return
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Call new_game() (will create a save dir — acceptable in test environment)
|
||||||
|
var orig_seed = GameState.get("world_seed")
|
||||||
|
var orig_game_id: String = GameState.current_game_id
|
||||||
|
sm.new_game()
|
||||||
|
var generated_seed = GameState.get("world_seed")
|
||||||
|
|
||||||
|
# world_seed must have been set to a non-null, non-zero value
|
||||||
|
assert_bool(generated_seed != null).override_failure_message(
|
||||||
|
"SessionManager.new_game() must set GameState.world_seed (#175)"
|
||||||
|
).is_true()
|
||||||
|
if generated_seed != null:
|
||||||
|
assert_bool(generated_seed != 0).override_failure_message(
|
||||||
|
"Generated world_seed must be non-zero"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
# Restore state
|
||||||
|
GameState.current_game_id = orig_game_id
|
||||||
|
GameState.world_seed = orig_seed
|
||||||
|
|
||||||
|
|
||||||
|
func test_session_manager_same_game_id_has_same_seed() -> void:
|
||||||
|
# Resuming a session must restore the original world_seed (not generate a new one).
|
||||||
|
# This ensures deterministic replays work correctly (D-010).
|
||||||
|
var sm = get_node_or_null("/root/SessionManager")
|
||||||
|
if sm == null:
|
||||||
|
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||||
|
return
|
||||||
|
if not sm.has_method("resume_game"):
|
||||||
|
push_warning("TestEntanglementSprint22: resume_game() missing — skipped")
|
||||||
|
return
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set a known seed and game_id, then resume — seed must not be clobbered
|
||||||
|
GameState.world_seed = 12345678
|
||||||
|
var orig_game_id: String = GameState.current_game_id
|
||||||
|
sm.resume_game("20260228-120000-abc123")
|
||||||
|
# resume_game() must NOT overwrite world_seed
|
||||||
|
assert_int(GameState.world_seed).override_failure_message(
|
||||||
|
"resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated"
|
||||||
|
).is_equal(12345678)
|
||||||
|
GameState.current_game_id = orig_game_id
|
||||||
|
|
||||||
|
|
||||||
|
# -- IPC startup message: world_seed field (#175) ----------------------------
|
||||||
|
|
||||||
|
func test_protocol_encode_startup_message_has_world_seed_field() -> void:
|
||||||
|
# #175 acceptance: startup IPC message must carry "world_seed" key.
|
||||||
|
# Verifies Protocol.encode_startup_message encodes the seed so the server
|
||||||
|
# can deserialize it as StartupMessage { world_seed: u64 }.
|
||||||
|
var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64
|
||||||
|
var bytes: PackedByteArray = Protocol.encode_startup_message(seed)
|
||||||
|
assert_bool(bytes.size() > 0).override_failure_message(
|
||||||
|
"Protocol.encode_startup_message must return non-empty bytes"
|
||||||
|
).is_true()
|
||||||
|
var decoded = Messagepack.decode(bytes)
|
||||||
|
assert_that(decoded.status).override_failure_message(
|
||||||
|
"encode_startup_message output must be valid msgpack: %s" % str(decoded.status)
|
||||||
|
).is_null()
|
||||||
|
var msg = decoded.value
|
||||||
|
assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message(
|
||||||
|
"StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg)
|
||||||
|
).is_true()
|
||||||
|
assert_int(msg["world_seed"]).override_failure_message(
|
||||||
|
"world_seed must round-trip through msgpack unchanged"
|
||||||
|
).is_equal(seed)
|
||||||
|
|
||||||
|
|
||||||
|
func test_protocol_encode_startup_message_zero_seed() -> void:
|
||||||
|
# Edge case: seed=0 must still encode a valid payload (world_seed: 0).
|
||||||
|
var bytes: PackedByteArray = Protocol.encode_startup_message(0)
|
||||||
|
assert_bool(bytes.size() > 0).is_true()
|
||||||
|
var decoded = Messagepack.decode(bytes)
|
||||||
|
assert_that(decoded.status).is_null()
|
||||||
|
assert_int(decoded.value["world_seed"]).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_sim_bridge_can_send_world_seed_in_startup() -> void:
|
||||||
|
# #175 acceptance: "startup IPC message carries a world_seed field"
|
||||||
|
# The client must be able to include world_seed in the session startup payload.
|
||||||
|
# Test-first: verify the API exists (method or field), else warn-and-skip.
|
||||||
|
var sim_bridge = get_node_or_null("/root/SimBridge")
|
||||||
|
if sim_bridge == null:
|
||||||
|
push_warning("TestEntanglementSprint22: SimBridge not found — skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Option A: SimBridge has a world_seed property that is sent during startup
|
||||||
|
if "world_seed" in sim_bridge:
|
||||||
|
sim_bridge.world_seed = 99999
|
||||||
|
assert_int(sim_bridge.world_seed).is_equal(99999)
|
||||||
|
sim_bridge.world_seed = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Option B: SimBridge has a set_world_seed() method
|
||||||
|
if sim_bridge.has_method("set_world_seed"):
|
||||||
|
# Method exists — this is the expected API
|
||||||
|
sim_bridge.set_world_seed(99999)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Neither found — test-first stub
|
||||||
|
push_warning(
|
||||||
|
"TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " +
|
||||||
|
"test-first stub awaiting #175 implementation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Protocol: world_seed flows from client to server (#175) ------------------
|
||||||
|
|
||||||
|
func test_apply_snapshot_does_not_clobber_world_seed() -> void:
|
||||||
|
# world_seed is set at session start and must persist across all subsequent snapshots.
|
||||||
|
# Snapshots must not overwrite or clear the world_seed that was set at startup.
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||||
|
return
|
||||||
|
GameState.world_seed = 42000
|
||||||
|
GameState.apply_snapshot({"tick": 5, "visible_tiles": []})
|
||||||
|
assert_int(GameState.world_seed).override_failure_message(
|
||||||
|
"apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start"
|
||||||
|
).is_equal(42000)
|
||||||
|
GameState.world_seed = null
|
||||||
|
|
||||||
|
|
||||||
|
# -- Seed variation property (#178, informational — full test is Rust-side) ---
|
||||||
|
|
||||||
|
func test_different_seeds_produce_different_configs_informational() -> void:
|
||||||
|
# D-029: "entanglement rate varies per seed to prevent metagaming calibration"
|
||||||
|
# The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs):
|
||||||
|
# - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic]
|
||||||
|
# - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%]
|
||||||
|
#
|
||||||
|
# This test only verifies the client side: world_seed is a u64 large enough to
|
||||||
|
# have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations;
|
||||||
|
# the full u64 seed provides 2^64 possibilities.
|
||||||
|
#
|
||||||
|
# We verify that two calls to new_game() produce different seeds.
|
||||||
|
var sm = get_node_or_null("/root/SessionManager")
|
||||||
|
if sm == null:
|
||||||
|
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||||
|
return
|
||||||
|
if not "world_seed" in GameState:
|
||||||
|
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||||
|
return
|
||||||
|
|
||||||
|
var orig_game_id: String = GameState.current_game_id
|
||||||
|
sm.new_game()
|
||||||
|
var seed_a = GameState.get("world_seed")
|
||||||
|
sm.new_game()
|
||||||
|
var seed_b = GameState.get("world_seed")
|
||||||
|
|
||||||
|
if seed_a == null or seed_b == null:
|
||||||
|
push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub")
|
||||||
|
GameState.current_game_id = orig_game_id
|
||||||
|
return
|
||||||
|
|
||||||
|
# Two different sessions should produce different seeds
|
||||||
|
assert_bool(seed_a != seed_b).override_failure_message(
|
||||||
|
"Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)"
|
||||||
|
).is_true()
|
||||||
|
GameState.current_game_id = orig_game_id
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hdoafqdfr6sq
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8e2watwphu41
|
||||||
@@ -268,6 +268,79 @@ func test_game_state_visible_positions_cleared_on_new_snapshot() -> void:
|
|||||||
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- #585: BoundaryWall tiles — visible in fog, not persistently explored ------
|
||||||
|
|
||||||
|
func test_boundary_wall_tiles_not_in_visible_positions() -> void:
|
||||||
|
## #585: BoundaryWall margin tiles must NOT enter visible_positions.
|
||||||
|
## They are rendered via tile_renderer (from visible_tiles) but must not
|
||||||
|
## update the player's fog exploration memory.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
|
||||||
|
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_false()
|
||||||
|
assert_that(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_boundary_wall_tiles_in_visibility_sectors() -> void:
|
||||||
|
## BoundaryWall visibility sector is still tracked in visibility_sectors
|
||||||
|
## (for potential future use — wall coloring, etc.)
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 3, "y": 3, "z": 0, "visibility": "BoundaryWall"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.visibility_sectors.has(Vector2i(3, 3))).is_true()
|
||||||
|
assert_that(GameState.visibility_sectors[Vector2i(3, 3)]).is_equal("BoundaryWall")
|
||||||
|
|
||||||
|
|
||||||
|
func test_boundary_positions_cleared_on_new_snapshot() -> void:
|
||||||
|
## BoundaryWall positions are cleared each snapshot so stale walls don't persist.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"visible_tiles": [{"x": 7, "y": 7, "z": 0, "visibility": "BoundaryWall"}],
|
||||||
|
})
|
||||||
|
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_true()
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 2,
|
||||||
|
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||||
|
})
|
||||||
|
assert_that(GameState.boundary_positions.has(Vector2i(7, 7))).is_false()
|
||||||
|
assert_that(GameState.boundary_positions.size()).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_boundary_wall_fog_vis_forward() -> void:
|
||||||
|
## #585: BoundaryWall tiles must lift fog (VIS_FORWARD = 255) so wall content composites.
|
||||||
|
## visible_positions excludes boundary tiles; fog_state writes vis bytes for them separately.
|
||||||
|
## Reads _vis_bytes directly (packed byte array) to avoid ImageTexture.get_image() lag.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 0, "y": 0, "z": 0, "visibility": "Forward"}, # normal LOS tile
|
||||||
|
{"x": 1, "y": 0, "z": 0, "visibility": "BoundaryWall"}, # margin tile
|
||||||
|
],
|
||||||
|
})
|
||||||
|
fog_state.update_from_state()
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.map_bounds.size.x
|
||||||
|
var vis: PackedByteArray = fog_state._vis_bytes
|
||||||
|
var normal_idx: int = (0 - oy) * w + (0 - ox)
|
||||||
|
var boundary_idx: int = (0 - oy) * w + (1 - ox)
|
||||||
|
assert_int(vis[normal_idx]).is_equal(FogState.VIS_FORWARD) # normal tile: VIS_FORWARD
|
||||||
|
assert_int(vis[boundary_idx]).is_equal(FogState.VIS_FORWARD) # boundary also fog-lifted
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.boundary_positions.clear()
|
||||||
|
|
||||||
|
|
||||||
# -- Z-layer compliance (D-049) -----------------------------------------------
|
# -- Z-layer compliance (D-049) -----------------------------------------------
|
||||||
|
|
||||||
func test_fog_overlay_z_layer() -> void:
|
func test_fog_overlay_z_layer() -> void:
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
## Sprint 22 — Fog system acceptance tests (#569)
|
||||||
|
##
|
||||||
|
## Validates FogState data management against the Sprint 22 acceptance criteria:
|
||||||
|
## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence)
|
||||||
|
## - Bounds grow-only invariant (explored tiles behind player stay in texture)
|
||||||
|
## - All visible tiles written as Forward (server simplified to Forward-only)
|
||||||
|
## - Exploration data survives texture resize (grow-only bounds copy)
|
||||||
|
## - Shader file present with correct fog_alpha constant
|
||||||
|
##
|
||||||
|
## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient)
|
||||||
|
## Ticket: #569
|
||||||
|
class_name TestFogSprint22
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func _get_fog_state() -> Node:
|
||||||
|
var node = get_node_or_null("/root/FogState")
|
||||||
|
if node == null:
|
||||||
|
push_warning("TestFogSprint22: FogState autoload not found — test skipped (awaiting #569)")
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.visible_tiles.clear()
|
||||||
|
GameState.visibility_sectors.clear()
|
||||||
|
GameState.boundary_positions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.visible_tiles.clear()
|
||||||
|
GameState.visibility_sectors.clear()
|
||||||
|
GameState.boundary_positions.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Spec constants (D-059) ---------------------------------------------------
|
||||||
|
|
||||||
|
func test_exp_explored_constant_is_128() -> void:
|
||||||
|
# EXP_EXPLORED = 128 → shader reads this as ~0.502.
|
||||||
|
# smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied.
|
||||||
|
# If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
assert_int(fog_state.EXP_EXPLORED).override_failure_message(
|
||||||
|
"EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering"
|
||||||
|
).is_equal(128)
|
||||||
|
|
||||||
|
|
||||||
|
func test_exp_unexplored_constant_is_0() -> void:
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
assert_int(fog_state.EXP_UNEXPLORED).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_exp_visible_constant_is_255() -> void:
|
||||||
|
# EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS)
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
assert_int(fog_state.EXP_VISIBLE).is_equal(255)
|
||||||
|
|
||||||
|
|
||||||
|
func test_vis_forward_constant_is_255() -> void:
|
||||||
|
# D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
assert_int(fog_state.VIS_FORWARD).is_equal(255)
|
||||||
|
|
||||||
|
|
||||||
|
func test_vis_hidden_constant_is_0() -> void:
|
||||||
|
# D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
assert_int(fog_state.VIS_HIDDEN).is_equal(0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_unexplored_color_spec_value() -> void:
|
||||||
|
# D-059: Unexplored = solid near-black #12141a
|
||||||
|
# Verify the hex value decodes to the expected channel values.
|
||||||
|
var c := Color("#12141a")
|
||||||
|
assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003)
|
||||||
|
assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003)
|
||||||
|
assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003)
|
||||||
|
# Sanity: it IS very dark (all channels < 0.12)
|
||||||
|
assert_float(c.r).is_less(0.12)
|
||||||
|
assert_float(c.g).is_less(0.12)
|
||||||
|
assert_float(c.b).is_less(0.12)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------
|
||||||
|
|
||||||
|
func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void:
|
||||||
|
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||||
|
# When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2),
|
||||||
|
# its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
push_warning("TestFogSprint22: update_from_state missing — skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Frame 1: tile (5,5) is visible
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||||
|
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
# Frame 2: tile (5,5) leaves LOS
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
# Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128)
|
||||||
|
var exp_bytes = fog_state.get("_exp_bytes")
|
||||||
|
if exp_bytes == null:
|
||||||
|
push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly")
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
push_warning("TestFogSprint22: _width inaccessible — data path untestable")
|
||||||
|
return
|
||||||
|
var px := 5 - ox
|
||||||
|
var py := 5 - oy
|
||||||
|
if px < 0 or py < 0 or px >= w:
|
||||||
|
push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin")
|
||||||
|
return
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx < 0 or idx >= exp_bytes.size():
|
||||||
|
push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()])
|
||||||
|
return
|
||||||
|
assert_int(exp_bytes[idx]).override_failure_message(
|
||||||
|
"Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)"
|
||||||
|
).is_equal(fog_state.EXP_EXPLORED)
|
||||||
|
|
||||||
|
|
||||||
|
func test_explored_tile_is_exp_visible_while_in_los() -> void:
|
||||||
|
# While in LOS, tile exploration byte must be EXP_VISIBLE (255)
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||||
|
GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
var exp_bytes = fog_state.get("_exp_bytes")
|
||||||
|
if exp_bytes == null:
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
var px := 3 - ox
|
||||||
|
var py := 3 - oy
|
||||||
|
if px < 0 or py < 0 or px >= w:
|
||||||
|
return
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx >= 0 and idx < exp_bytes.size():
|
||||||
|
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE)
|
||||||
|
|
||||||
|
|
||||||
|
func test_unexplored_tile_stays_exp_unexplored() -> void:
|
||||||
|
# Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0)
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# See only (5, 5) — tile (7, 8) is not in LOS
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||||
|
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
var exp_bytes = fog_state.get("_exp_bytes")
|
||||||
|
if exp_bytes == null:
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
var px := 7 - ox
|
||||||
|
var py := 8 - oy
|
||||||
|
if px < 0 or py < 0 or px >= w:
|
||||||
|
return
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx >= 0 and idx < exp_bytes.size():
|
||||||
|
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Acceptance: bounds grow-only invariant ------------------------------------
|
||||||
|
|
||||||
|
func test_bounds_never_shrink() -> void:
|
||||||
|
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||||
|
# Requires grow-only bounds: once a tile is in the texture, it stays there.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Frame 1: see (10, 10) → establishes initial bounds
|
||||||
|
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||||
|
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
var b1: Rect2i = fog_state.map_bounds
|
||||||
|
|
||||||
|
# Frame 2: see (30, 30) → bounds must expand to include both
|
||||||
|
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||||
|
GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
var b2: Rect2i = fog_state.map_bounds
|
||||||
|
|
||||||
|
# Frame 3: back to (10, 10) → bounds must NOT shrink
|
||||||
|
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||||
|
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
var b3: Rect2i = fog_state.map_bounds
|
||||||
|
|
||||||
|
assert_bool(b2.size.x >= b1.size.x).override_failure_message(
|
||||||
|
"Bounds must grow when player moves to larger region"
|
||||||
|
).is_true()
|
||||||
|
assert_bool(b2.size.y >= b1.size.y).is_true()
|
||||||
|
assert_bool(b3.size.x >= b2.size.x).override_failure_message(
|
||||||
|
"Bounds must not shrink when player returns to previous position (grow-only invariant)"
|
||||||
|
).is_true()
|
||||||
|
assert_bool(b3.size.y >= b2.size.y).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_bounds_include_margin_for_gradient_bleed() -> void:
|
||||||
|
# D-066: 6-8 tile gradient at cone edge requires texture margin.
|
||||||
|
# _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian
|
||||||
|
# kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10),
|
||||||
|
# bounds should extend at least 4 tiles beyond the visible tile.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||||
|
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
var b: Rect2i = fog_state.map_bounds
|
||||||
|
# With 4-tile margin: bounds.position.x <= 10 - 4 = 6
|
||||||
|
assert_bool(b.position.x <= 6).override_failure_message(
|
||||||
|
"FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)"
|
||||||
|
).is_true()
|
||||||
|
assert_bool(b.position.y <= 6).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ----
|
||||||
|
|
||||||
|
func test_visible_tiles_written_as_vis_forward() -> void:
|
||||||
|
# Sprint 22: server sends only Forward tiles (Peripheral sector removed).
|
||||||
|
# FogState writes VIS_FORWARD (255) for all tiles in visible_positions.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||||
|
GameState.visible_tiles = [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "visibility": "Forward"},
|
||||||
|
]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
var vis_bytes = fog_state.get("_vis_bytes")
|
||||||
|
if vis_bytes == null:
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
for pos in [Vector2i(5, 5), Vector2i(6, 5)]:
|
||||||
|
var px := pos.x - ox
|
||||||
|
var py := pos.y - oy
|
||||||
|
if px < 0 or py < 0 or px >= w:
|
||||||
|
continue
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx >= 0 and idx < vis_bytes.size():
|
||||||
|
assert_int(vis_bytes[idx]).override_failure_message(
|
||||||
|
"All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22"
|
||||||
|
).is_equal(fog_state.VIS_FORWARD)
|
||||||
|
|
||||||
|
|
||||||
|
func test_tiles_outside_los_written_as_vis_hidden() -> void:
|
||||||
|
# Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0)
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||||
|
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
# (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN
|
||||||
|
var vis_bytes = fog_state.get("_vis_bytes")
|
||||||
|
if vis_bytes == null:
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
var px := 5 - ox
|
||||||
|
var py := 7 - oy
|
||||||
|
if px >= 0 and py >= 0 and px < w:
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx >= 0 and idx < vis_bytes.size():
|
||||||
|
assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Acceptance: exploration survives texture resize --------------------------
|
||||||
|
|
||||||
|
func test_exploration_data_preserved_across_bounds_growth() -> void:
|
||||||
|
# D-059: Texture resize must copy old exploration bytes into new texture.
|
||||||
|
# Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black).
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Frame 1: see (5, 5), then leave
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||||
|
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
fog_state.update_from_state() # (5,5) → EXP_EXPLORED
|
||||||
|
|
||||||
|
# Frame 2: move far away — forces bounds growth (resize)
|
||||||
|
GameState.visible_positions = {Vector2i(80, 80): true}
|
||||||
|
GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
# (5,5) must still be EXP_EXPLORED after the resize
|
||||||
|
var exp_bytes = fog_state.get("_exp_bytes")
|
||||||
|
if exp_bytes == null:
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
var px := 5 - ox
|
||||||
|
var py := 5 - oy
|
||||||
|
if px < 0 or py < 0 or px >= w:
|
||||||
|
push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?")
|
||||||
|
return
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx >= 0 and idx < exp_bytes.size():
|
||||||
|
assert_int(exp_bytes[idx]).override_failure_message(
|
||||||
|
"Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize"
|
||||||
|
).is_greater_equal(fog_state.EXP_EXPLORED)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Shader file checks (D-059) -----------------------------------------------
|
||||||
|
|
||||||
|
func test_fog_gdshader_exists() -> void:
|
||||||
|
assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||||
|
"fog.gdshader must exist — fog rendering requires this shader file (#569)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fog_shader_defines_fog_alpha() -> void:
|
||||||
|
# D-059: explored fog overlay must be ~25-30% opacity.
|
||||||
|
# fog_alpha constant controls this. Verify the shader defines it.
|
||||||
|
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||||
|
push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped")
|
||||||
|
return
|
||||||
|
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||||
|
if source.is_empty():
|
||||||
|
push_warning("TestFogSprint22: fog.gdshader is empty or unreadable")
|
||||||
|
return
|
||||||
|
assert_bool(source.contains("fog_alpha")).override_failure_message(
|
||||||
|
"fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fog_shader_defines_smoothstep_clarity_ramp() -> void:
|
||||||
|
# D-059/D-066: smooth gradient requires a clarity ramp (smoothstep).
|
||||||
|
# The blurred visibility → clarity ramp must use smoothstep for smooth gradients.
|
||||||
|
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||||
|
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||||
|
return
|
||||||
|
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||||
|
if source.is_empty():
|
||||||
|
return
|
||||||
|
assert_bool(source.contains("smoothstep")).override_failure_message(
|
||||||
|
"fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fog_shader_defines_unexplored_color() -> void:
|
||||||
|
# D-059: unexplored = solid near-black #12141a.
|
||||||
|
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||||
|
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||||
|
return
|
||||||
|
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||||
|
if source.is_empty():
|
||||||
|
return
|
||||||
|
assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message(
|
||||||
|
"fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fog_shader_uses_gaussian_blur_for_gradient() -> void:
|
||||||
|
# D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture.
|
||||||
|
# Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0
|
||||||
|
# in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14.
|
||||||
|
# This covers the D-066 "6-8 tile" gradient spec.
|
||||||
|
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||||
|
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||||
|
return
|
||||||
|
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||||
|
if source.is_empty():
|
||||||
|
return
|
||||||
|
# 7x7 Gaussian uses dy from -3 to 3
|
||||||
|
assert_bool(source.contains("sample_visibility")).override_failure_message(
|
||||||
|
"fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)"
|
||||||
|
).is_true()
|
||||||
|
assert_bool(source.contains("-3.0")).override_failure_message(
|
||||||
|
"fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Regression: GameState visible_positions (existing contract) ---------------
|
||||||
|
|
||||||
|
func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void:
|
||||||
|
# D-020: In real server mode, visible_positions derives from visible_tiles.
|
||||||
|
# Fog rendering depends on this derivation being correct.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 10,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 7, "y": 7, "z": 0, "visibility": "Forward"},
|
||||||
|
{"x": 8, "y": 7, "z": 0, "visibility": "Forward"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message(
|
||||||
|
"visible_positions must be derived from visible_tiles when no explicit visible_positions key"
|
||||||
|
).is_true()
|
||||||
|
assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_visibility_sectors_populated_forward_only() -> void:
|
||||||
|
# D-015: visibility_sectors must be populated from visible_tiles.
|
||||||
|
# In Forward-only mode, all sectors are "Forward".
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 11,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 4, "y": 4, "z": 0, "visibility": "Forward"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true()
|
||||||
|
assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward")
|
||||||
|
|
||||||
|
|
||||||
|
func test_visible_positions_cleared_on_new_snapshot() -> void:
|
||||||
|
# Old positions from tick N must not persist to tick N+1
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
|
||||||
|
})
|
||||||
|
assert_int(GameState.visible_positions.size()).is_equal(1)
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 2,
|
||||||
|
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||||
|
})
|
||||||
|
assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||||
|
"Old visible positions must be cleared when new visible_tiles arrive"
|
||||||
|
).is_false()
|
||||||
|
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
|
||||||
|
|
||||||
|
func test_boundary_positions_populated_from_snapshot() -> void:
|
||||||
|
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
|
||||||
|
# Fog lifts for boundary wall tiles so wall content composites correctly.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 20,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||||
|
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
|
||||||
|
"Forward tile must be in visible_positions"
|
||||||
|
).is_true()
|
||||||
|
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||||
|
"BoundaryWall tile must NOT be in visible_positions (#585)"
|
||||||
|
).is_false()
|
||||||
|
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||||
|
"BoundaryWall tile must be in boundary_positions (#585)"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
|
||||||
|
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
|
||||||
|
# They render through fog but are not stored as exploration memory.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||||
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||||
|
GameState.visible_tiles = [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||||
|
]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
var vis_bytes = fog_state.get("_vis_bytes")
|
||||||
|
var exp_bytes = fog_state.get("_exp_bytes")
|
||||||
|
if vis_bytes == null or exp_bytes == null:
|
||||||
|
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
var px := 6 - ox
|
||||||
|
var py := 5 - oy
|
||||||
|
if px < 0 or py < 0 or px >= w:
|
||||||
|
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
|
||||||
|
return
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx < 0 or idx >= vis_bytes.size():
|
||||||
|
return
|
||||||
|
assert_int(vis_bytes[idx]).override_failure_message(
|
||||||
|
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
|
||||||
|
).is_equal(fog_state.VIS_FORWARD)
|
||||||
|
assert_int(exp_bytes[idx]).override_failure_message(
|
||||||
|
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
|
||||||
|
).is_not_equal(fog_state.EXP_VISIBLE)
|
||||||
|
|
||||||
|
|
||||||
|
func test_boundary_wall_stays_unexplored_after_leaving_los() -> void:
|
||||||
|
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
|
||||||
|
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
|
||||||
|
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Frame 1: BoundaryWall at (6,5) is visible
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||||
|
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||||
|
GameState.visible_tiles = [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||||
|
]
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
# Frame 2: both leave LOS
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.boundary_positions.clear()
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
fog_state.update_from_state()
|
||||||
|
|
||||||
|
var exp_bytes = fog_state.get("_exp_bytes")
|
||||||
|
if exp_bytes == null:
|
||||||
|
return
|
||||||
|
var ox: int = fog_state.map_bounds.position.x
|
||||||
|
var oy: int = fog_state.map_bounds.position.y
|
||||||
|
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||||
|
if w <= 0:
|
||||||
|
return
|
||||||
|
var px := 6 - ox
|
||||||
|
var py := 5 - oy
|
||||||
|
if px >= 0 and py >= 0 and px < w:
|
||||||
|
var idx := py * w + px
|
||||||
|
if idx >= 0 and idx < exp_bytes.size():
|
||||||
|
assert_int(exp_bytes[idx]).override_failure_message(
|
||||||
|
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
|
||||||
|
).is_equal(fog_state.EXP_UNEXPLORED)
|
||||||
|
|
||||||
|
|
||||||
|
func test_boundary_wall_cleared_on_new_snapshot() -> void:
|
||||||
|
# #585: boundary_positions must be cleared each tick — old walls must not persist.
|
||||||
|
# BoundaryWall positions shift as the player moves; stale positions would lift fog
|
||||||
|
# where no wall exists.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 30,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
||||||
|
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 31,
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
|
||||||
|
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
|
||||||
|
).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Performance (D-059) -------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
|
||||||
|
# D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env.
|
||||||
|
var fog_state = _get_fog_state()
|
||||||
|
if fog_state == null:
|
||||||
|
return
|
||||||
|
if not fog_state.has_method("update_from_state"):
|
||||||
|
return
|
||||||
|
|
||||||
|
var positions: Dictionary = {}
|
||||||
|
var tiles: Array = []
|
||||||
|
for x in range(20):
|
||||||
|
for y in range(20):
|
||||||
|
positions[Vector2i(x, y)] = true
|
||||||
|
tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"})
|
||||||
|
GameState.visible_positions = positions
|
||||||
|
GameState.visible_tiles = tiles
|
||||||
|
|
||||||
|
var start := Time.get_ticks_usec()
|
||||||
|
fog_state.update_from_state()
|
||||||
|
var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0
|
||||||
|
|
||||||
|
assert_float(elapsed_ms).override_failure_message(
|
||||||
|
"FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)"
|
||||||
|
).is_less(2.0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dvt7ytxff0id
|
||||||