Compare commits
114
Commits
@@ -114,7 +114,7 @@ Synthesize findings.
|
||||
|
||||
### Qatux (Documenter & Librarian)
|
||||
- Core team member — participates in discussion rounds as documenter
|
||||
- Manages document search via `/search-docs` skill
|
||||
- Manages document search via `/docs-search` skill
|
||||
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
|
||||
- Answers "did we discuss this?" with citations
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
- Provide "state of the project" summaries when asked
|
||||
|
||||
### Knowledge management
|
||||
- Maintain the Qdrant document index via /search-docs skill
|
||||
- Maintain the Qdrant document index via /docs-search skill
|
||||
- Update briefing files when decisions change
|
||||
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
|
||||
- Catch staleness in briefings and flag for update
|
||||
|
||||
@@ -50,10 +50,11 @@
|
||||
"Bash(find *)",
|
||||
"Bash(list *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(sed -n *)",
|
||||
|
||||
"Skill(commit)",
|
||||
"Skill(git-commit)",
|
||||
"Skill(worktree-update)",
|
||||
"Skill(start-sprint)"
|
||||
"Skill(sprint-start)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force *)",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: audio-gen
|
||||
description: >
|
||||
Generate audio assets for The Settled Reach using the Stable Audio Open API
|
||||
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
|
||||
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
|
||||
sound asset from docs/assets/audio/. Also use when the user asks about audio
|
||||
generation, sound design pipeline, or audio asset iteration. Triggers on:
|
||||
"generate audio", "make sounds", "create ambient", "audio pipeline",
|
||||
"generate sfx", "stable audio", "gen audio", "sound design".
|
||||
---
|
||||
|
||||
# Audio Generation — The Settled Reach
|
||||
|
||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
||||
wrapper scripts at `db/connectors/audio-*`.
|
||||
|
||||
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||
workflow, and quality validation.
|
||||
|
||||
## API Access
|
||||
|
||||
**Never call the API directly.** Use the wrapper scripts:
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
db/connectors/audio-health
|
||||
|
||||
# Generate a single asset (WAV only)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/output.wav
|
||||
|
||||
# Generate + post-process in one command (WAV → trim → normalize → OGG)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/gen/intermediate.wav \
|
||||
--output-ogg client/assets/audio/final.ogg
|
||||
|
||||
# Batch-generate from a manifest (preferred for multiple assets)
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Default | Range | Notes |
|
||||
|-----------|---------|-------|-------|
|
||||
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
|
||||
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
|
||||
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
|
||||
| `--output` | auto | — | Output WAV file path. Auto-names from prompt if omitted. |
|
||||
| `--post` | off | — | Run trim + normalize + convert after generation. |
|
||||
| `--output-ogg` | auto | — | OGG output path (implies `--post`). Defaults to same basename as WAV. |
|
||||
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
|
||||
|
||||
### Critical Constraints
|
||||
|
||||
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
|
||||
generation at a time. Always wait for a generation to complete before
|
||||
starting the next. Sequential only.
|
||||
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
|
||||
Be patient. The timeout default (600s) is generous.
|
||||
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
|
||||
clips and crossfade-stitch in post-processing.
|
||||
|
||||
## Prompt Assembly
|
||||
|
||||
Every generation uses three parts:
|
||||
|
||||
```
|
||||
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
|
||||
```
|
||||
|
||||
Never call the API with just the asset description. Always prepend the sonic
|
||||
family prefix and matching category template.
|
||||
|
||||
- **Sonic palette and families:** Read `references/sonic-palette.md`
|
||||
- **Category templates:** Read `references/category-templates.md` and match by
|
||||
asset type (ambient, sfx, ui)
|
||||
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
|
||||
|
||||
## Batch Workflow (Preferred)
|
||||
|
||||
For generating multiple assets, use a manifest file. This reduces prompt
|
||||
approvals to 2: one Write (manifest) + one Bash (batch run).
|
||||
|
||||
### 1. Create the manifest
|
||||
|
||||
Write a JSON manifest to `docs/assets/audio/batch-{sprint}-{ticket}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Sprint 10 ambient + world SFX batch",
|
||||
"output_dir": "client/assets/audio",
|
||||
"gen_dir": "client/assets/audio/gen",
|
||||
"defaults": {
|
||||
"steps": 100,
|
||||
"cfg": 7,
|
||||
"lufs": -16,
|
||||
"quality": 6
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "AMB-001",
|
||||
"filename": "amb_station_base.ogg",
|
||||
"method": "sao",
|
||||
"duration": 45,
|
||||
"steps": 150,
|
||||
"cfg": 5,
|
||||
"prompt": "[sonic family prefix] + [template] + [description]"
|
||||
},
|
||||
{
|
||||
"id": "UI-005",
|
||||
"filename": "sfx_monologue_chime.ogg",
|
||||
"method": "synth",
|
||||
"synth": {
|
||||
"type": "harmonic",
|
||||
"duration": 0.8,
|
||||
"fundamental": 1200,
|
||||
"harmonics": [
|
||||
{"freq": 2400, "db": -12},
|
||||
{"freq": 3600, "db": -24}
|
||||
],
|
||||
"attack_ms": 15,
|
||||
"sustain_ratio": 0.2,
|
||||
"decay": "exponential"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g.,
|
||||
AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
|
||||
|
||||
### 2. Run the batch
|
||||
|
||||
```bash
|
||||
# Full run
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
|
||||
# Dry run — preview what would be generated
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
|
||||
# Generate only specific assets
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
|
||||
# Skip assets that already have OGG files
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
```
|
||||
|
||||
### 3. Update asset docs with prompts
|
||||
|
||||
After the batch completes, write the exact prompts used back into the
|
||||
Prompt/Notes column of `docs/assets/audio/{category}.md`. The manifest records
|
||||
what was generated; the asset docs record what we have.
|
||||
|
||||
### Manifest fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `id` | yes | Asset ID from docs (AMB-001, SFX-002, UI-005) |
|
||||
| `filename` | yes | Output filename (must match asset doc) |
|
||||
| `method` | yes | `sao` (Stable Audio Open) or `synth` (harmonic synthesis) |
|
||||
| `duration` | SAO only | Duration in seconds |
|
||||
| `prompt` | SAO only | Full assembled prompt |
|
||||
| `steps` | no | Override default steps |
|
||||
| `cfg` | no | Override default CFG |
|
||||
| `synth` | synth only | Synthesis parameters (see below) |
|
||||
|
||||
### Synth parameters
|
||||
|
||||
| Field | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `type` | harmonic | Only `harmonic` supported currently |
|
||||
| `duration` | — | Duration in seconds |
|
||||
| `fundamental` | — | Fundamental frequency in Hz |
|
||||
| `harmonics` | [] | List of `{"freq": Hz, "db": dB}` objects |
|
||||
| `attack_ms` | 10 | Attack time in milliseconds |
|
||||
| `sustain_ratio` | 0.2 | Fraction of duration at full level before decay |
|
||||
| `decay` | exponential | `exponential` or `linear` |
|
||||
|
||||
## Single Asset Workflow
|
||||
|
||||
For one-off generation or iteration on a specific asset:
|
||||
|
||||
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
|
||||
duration, bus, method, and design intent.
|
||||
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||
3. Read `references/category-templates.md` for the matching template.
|
||||
4. Assemble the full prompt.
|
||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
||||
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
|
||||
generate and post-process in one step.
|
||||
7. Verify the output (file size, duration).
|
||||
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
|
||||
|
||||
## Iteration Workflow
|
||||
|
||||
For each asset, generate 4-6 candidates:
|
||||
|
||||
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
|
||||
adjust CFG between 5-9). Run each generation sequentially — never in
|
||||
parallel.
|
||||
2. **Solo test** — does each candidate sound right alone?
|
||||
3. **Stack test** — play the candidate alongside other layers. Does it mask or
|
||||
clash?
|
||||
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
|
||||
jarring repeat?
|
||||
5. **Close-your-eyes test** — does it create a mental image or sensation?
|
||||
6. Select the best candidate (post-processing is already done if `--post` was
|
||||
used).
|
||||
|
||||
## Post-Processing (Standalone)
|
||||
|
||||
If you need to post-process separately (e.g., re-normalizing an existing file):
|
||||
|
||||
```bash
|
||||
# Full pipeline: trim → normalize → convert
|
||||
db/connectors/audio-post pipeline input.wav --output output.ogg
|
||||
|
||||
# Individual steps
|
||||
db/connectors/audio-post trim input.wav
|
||||
db/connectors/audio-post normalize input.wav --lufs -16
|
||||
db/connectors/audio-post convert input.wav --output output.ogg
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
|
||||
produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py`
|
||||
or the batch manifest's `method: "synth"` with harmonic parameters.
|
||||
|
||||
For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass
|
||||
impulse), write a custom script in `tooling/` following the pattern in
|
||||
`tooling/synth_ui_sounds.py`.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
After generating, verify:
|
||||
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
|
||||
- Frequency range doesn't mask other layers (check docs/assets/audio/)
|
||||
- Duration matches spec
|
||||
- No unwanted artifacts (clicks, pops, digital noise at start/end)
|
||||
- Loop point is clean (ambient loops only)
|
||||
- Volume sits well relative to other assets (LUFS normalized)
|
||||
- Passes the close-your-eyes test
|
||||
|
||||
## File Placement
|
||||
|
||||
Generated assets go to `client/assets/audio/` with exact filenames from the
|
||||
asset docs. Intermediates go to `client/assets/audio/gen/` (gitignored).
|
||||
|
||||
AudioManager discovers assets by directory scan — filenames must match exactly.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: search-docs
|
||||
name: docs-search
|
||||
description: >
|
||||
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
|
||||
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
|
||||
or invokes /search-docs. Wraps the qdrant_connector.py for semantic document search.
|
||||
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob
|
||||
---
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
name: gen-audio
|
||||
description: >
|
||||
Generate audio assets for The Settled Reach using the Stable Audio Open API
|
||||
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
|
||||
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
|
||||
sound asset from docs/assets/audio/. Also use when the user asks about audio
|
||||
generation, sound design pipeline, or audio asset iteration. Triggers on:
|
||||
"generate audio", "make sounds", "create ambient", "audio pipeline",
|
||||
"generate sfx", "stable audio", "gen audio", "sound design".
|
||||
---
|
||||
|
||||
# Audio Generation — The Settled Reach
|
||||
|
||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
||||
wrapper scripts at `db/connectors/audio-*`.
|
||||
|
||||
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||
workflow, and quality validation.
|
||||
|
||||
## API Access
|
||||
|
||||
**Never call the API directly.** Use the wrapper scripts:
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
db/connectors/audio-health
|
||||
|
||||
# Generate audio
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 \
|
||||
--steps 100 \
|
||||
--cfg 7 \
|
||||
--output path/to/output.wav \
|
||||
--timeout 600
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Default | Range | Notes |
|
||||
|-----------|---------|-------|-------|
|
||||
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
|
||||
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
|
||||
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
|
||||
| `--output` | auto | — | Output file path. Auto-names from prompt if omitted. |
|
||||
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
|
||||
|
||||
### Critical Constraints
|
||||
|
||||
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
|
||||
generation at a time. Always wait for a generation to complete before
|
||||
starting the next. Sequential only.
|
||||
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
|
||||
Be patient. The timeout default (600s) is generous.
|
||||
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
|
||||
clips and crossfade-stitch in post-processing.
|
||||
- **Output is WAV at 44.1kHz stereo.** Convert to .ogg for Godot import:
|
||||
`ffmpeg -i input.wav -c:a libvorbis -q:a 6 output.ogg`
|
||||
|
||||
## Prompt Assembly
|
||||
|
||||
Every generation uses three parts:
|
||||
|
||||
```
|
||||
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
|
||||
```
|
||||
|
||||
Never call the API with just the asset description. Always prepend the sonic
|
||||
family prefix and matching category template.
|
||||
|
||||
- **Sonic palette and families:** Read `references/sonic-palette.md`
|
||||
- **Category templates:** Read `references/category-templates.md` and match by
|
||||
asset type (ambient, sfx, ui)
|
||||
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
|
||||
|
||||
## Single Asset Workflow
|
||||
|
||||
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
|
||||
duration, bus, method, and design intent.
|
||||
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||
3. Read `references/category-templates.md` for the matching template.
|
||||
4. Assemble the full prompt.
|
||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
||||
6. Run `db/connectors/audio-generate` with the assembled prompt. **One request
|
||||
at a time. Wait for completion.**
|
||||
7. Listen to the output (or describe it based on file size/duration).
|
||||
8. If acceptable, convert to .ogg and place in `client/assets/audio/`.
|
||||
9. Update the asset status in `docs/assets/audio/{category}.md`.
|
||||
|
||||
## Iteration Workflow
|
||||
|
||||
For each asset, generate 4-6 candidates:
|
||||
|
||||
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
|
||||
adjust CFG between 5-9). Run each generation sequentially — never in
|
||||
parallel.
|
||||
2. **Solo test** — does each candidate sound right alone?
|
||||
3. **Stack test** — play the candidate alongside other layers. Does it mask or
|
||||
clash?
|
||||
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
|
||||
jarring repeat?
|
||||
5. **Close-your-eyes test** — does it create a mental image or sensation?
|
||||
6. Select the best candidate, trim, normalize, convert.
|
||||
|
||||
## Post-Processing
|
||||
|
||||
After selecting the best generation:
|
||||
|
||||
```bash
|
||||
# Trim silence from start/end
|
||||
ffmpeg -i input.wav -af "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse" trimmed.wav
|
||||
|
||||
# LUFS normalize to -16 LUFS (broadcast standard, good for game audio)
|
||||
ffmpeg -i trimmed.wav -af loudnorm=I=-16:LRA=11:TP=-1 normalized.wav
|
||||
|
||||
# Convert to .ogg for Godot
|
||||
ffmpeg -i normalized.wav -c:a libvorbis -q:a 6 output.ogg
|
||||
|
||||
# For loops: verify loop point
|
||||
ffplay -loop 0 output.ogg
|
||||
```
|
||||
|
||||
For ambient loops, create crossfade overlap:
|
||||
```bash
|
||||
# Create a 45s loop with 3s crossfade overlap
|
||||
# (manual: export 48s, crossfade first 3s with last 3s in Audacity)
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
|
||||
produce meaningful output. Use manual synthesis instead:
|
||||
|
||||
```python
|
||||
# Example: 50ms cursor hover tick
|
||||
import numpy as np
|
||||
import wave
|
||||
|
||||
sr = 44100
|
||||
duration = 0.05 # 50ms
|
||||
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
|
||||
freq = 3200 # Hz
|
||||
signal = np.sin(2 * np.pi * freq * t)
|
||||
envelope = np.exp(-t * 80) # exponential decay
|
||||
audio = (signal * envelope * 32767).astype(np.int16)
|
||||
|
||||
with wave.open("cursor_hover.wav", "w") as f:
|
||||
f.setnchannels(1)
|
||||
f.setsampwidth(2)
|
||||
f.setframerate(sr)
|
||||
f.writeframes(audio.tobytes())
|
||||
```
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
After generating, verify:
|
||||
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
|
||||
- Frequency range doesn't mask other layers (check docs/assets/audio/palette.md)
|
||||
- Duration matches spec
|
||||
- No unwanted artifacts (clicks, pops, digital noise at start/end)
|
||||
- Loop point is clean (ambient loops only)
|
||||
- Volume sits well relative to other assets (LUFS normalized)
|
||||
- Passes the close-your-eyes test
|
||||
|
||||
## File Placement
|
||||
|
||||
Generated assets go to `client/assets/audio/` with exact filenames from the
|
||||
asset docs:
|
||||
|
||||
```
|
||||
client/assets/audio/
|
||||
amb_station_base.ogg # Ambient bus
|
||||
amb_workplace_layer.ogg # Ambient bus
|
||||
amb_bar_layer.ogg # Ambient bus
|
||||
amb_corridor_layer.ogg # Ambient bus
|
||||
sfx_footstep_metal.ogg # Player Actions bus
|
||||
sfx_footstep_metal_run.ogg # Player Actions bus
|
||||
cursor_hover.ogg # UI Sounds bus
|
||||
implant_open.ogg # UI Sounds bus
|
||||
fog_recognition.ogg # UI Sounds bus
|
||||
weapon_aim.ogg # UI Sounds bus
|
||||
sfx_monologue_chime.ogg # UI Sounds bus
|
||||
sfx_monologue_chime_urgent.ogg # UI Sounds bus
|
||||
```
|
||||
|
||||
AudioManager discovers these by directory scan — filenames must match exactly.
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: commit
|
||||
name: git-commit
|
||||
description: >
|
||||
Commit changes with clean, structured messages. Use when the user says
|
||||
"commit", "save my work", "commit changes", or invokes /commit. Enforces
|
||||
"commit", "save my work", "commit changes", or invokes /git-commit. Enforces
|
||||
conventional commit format, groups changes into logical commits, and maintains
|
||||
CHANGELOG.md. Never squash unrelated changes into one commit.
|
||||
user-invocable: true
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: gen-image
|
||||
name: image-gen
|
||||
description: >
|
||||
Generate themed visual assets for the Lords of Ash CK3 total conversion mod
|
||||
using the generate_image MCP tool (Nano Banana / Gemini 2.5 Flash Image).
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
name: push-pr
|
||||
name: pr-push
|
||||
description: >
|
||||
Push commits and create or update a pull request. Use when the user says
|
||||
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
|
||||
or invokes /push-pr. NOT triggered by plain "push" (that's just git push).
|
||||
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
|
||||
Pushes the current branch, creates a PR if none exists, or confirms the
|
||||
existing PR was updated. NEVER merges the PR into main — this skill only
|
||||
pushes to the branch and manages the PR lifecycle.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion
|
||||
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
|
||||
---
|
||||
|
||||
# Push PR Skill
|
||||
@@ -34,17 +34,29 @@ git branch --show-current
|
||||
|
||||
If on `main`, stop: "You're on main. Switch to a team branch first."
|
||||
|
||||
### 2. Check for unpushed commits
|
||||
### 2. Commit uncommitted changes
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes (staged or unstaged), run the **commit skill**
|
||||
first. Use the `/git-commit` skill to group changes into logical commits with
|
||||
proper conventional commit messages. Wait for commit to complete before
|
||||
proceeding.
|
||||
|
||||
If the working tree is clean (no uncommitted changes), skip to step 3.
|
||||
|
||||
### 3. Check for unpushed commits
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git status
|
||||
git log --oneline origin/<branch>..<branch>
|
||||
```
|
||||
|
||||
If no unpushed commits, skip to step 4 (PR check).
|
||||
If no unpushed commits, skip to step 5 (PR check).
|
||||
|
||||
### 3. Check for conflicts with main
|
||||
### 4. Check for conflicts with main
|
||||
|
||||
```bash
|
||||
git merge-tree --write-tree origin/main HEAD 2>&1
|
||||
@@ -59,7 +71,7 @@ git merge origin/main --no-edit
|
||||
If merge conflicts, **stop and report** — let the user resolve.
|
||||
If clean, continue.
|
||||
|
||||
### 4. Push
|
||||
### 5. Push
|
||||
|
||||
```bash
|
||||
git push origin <branch>
|
||||
@@ -67,7 +79,7 @@ git push origin <branch>
|
||||
|
||||
If push fails, stop and report. Never force-push without explicit request.
|
||||
|
||||
### 5. Check for existing PR
|
||||
### 6. Check for existing PR
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
@@ -76,9 +88,9 @@ tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --o
|
||||
Match current branch name in PR list.
|
||||
|
||||
- **PR exists**: Report "Pushed N commits to `<branch>`. PR #X updated." Done.
|
||||
- **No PR**: Continue to step 6.
|
||||
- **No PR**: Continue to step 7.
|
||||
|
||||
### 6. Create a new PR
|
||||
### 7. Create a new PR
|
||||
|
||||
```bash
|
||||
git log --oneline main..<branch>
|
||||
@@ -103,7 +115,26 @@ tea pr create \
|
||||
|
||||
Report PR URL when done.
|
||||
|
||||
### 8. Update ticket status to review
|
||||
|
||||
Scan all commit messages in the PR for ticket references (`#NNN`):
|
||||
|
||||
```bash
|
||||
git log --oneline main..<branch>
|
||||
```
|
||||
|
||||
Extract ticket IDs from `#NNN` patterns. For each ticket that is
|
||||
currently `in_progress`, update it to `review`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> review
|
||||
```
|
||||
|
||||
Report which tickets were moved to review. Skip tickets that are
|
||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
`in_progress` → `review`).
|
||||
|
||||
## Arguments
|
||||
|
||||
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
|
||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||
PR title instead of generating one.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: review-pr
|
||||
name: pr-review
|
||||
description: >
|
||||
Review a branch diff with team-appropriate agents before merge. Use when the
|
||||
user says "review-pr", "review this PR", "review this branch", or invokes
|
||||
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
/pr-review. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
audio) in parallel. Reports approve/reject with inline comments.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Task
|
||||
@@ -140,6 +140,10 @@ Respond with:
|
||||
- File path and approximate location
|
||||
- Severity: critical / warning / suggestion
|
||||
- Description of the issue
|
||||
|
||||
Verdict rules:
|
||||
- Any **critical** or **warning** issue → REQUEST_CHANGES
|
||||
- Only **suggestion**-level issues (or none) → APPROVE
|
||||
If no issues found, say APPROVE with a brief positive summary.
|
||||
```
|
||||
|
||||
@@ -152,19 +156,16 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
||||
|
||||
```bash
|
||||
# Step 1: Write review to temp file (use Bash heredoc, NOT the Write tool)
|
||||
cat > /tmp/review-<branch>.md << 'EOF'
|
||||
## Review: <branch> -> main
|
||||
...review content...
|
||||
EOF
|
||||
```
|
||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review-<branch>.md)"
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
These MUST be separate sequential Bash calls — do not chain them in a single
|
||||
command. The heredoc write must complete before `tea comment` reads the file.
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: create-skill
|
||||
name: skill-create
|
||||
description: >
|
||||
Guidance for creating effective Claude Code skills (.skill packages).
|
||||
Use when the user wants to create, build, design, or iterate on a skill —
|
||||
@@ -178,7 +178,11 @@ For design patterns, consult:
|
||||
**Writing guidelines:** Always use imperative/infinitive form.
|
||||
|
||||
**Frontmatter:**
|
||||
- `name`: The skill name
|
||||
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
|
||||
The domain is the system/area the skill operates on, the action is what it does.
|
||||
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
|
||||
Multi-action wrappers (like `ticket`) can use the domain name alone.
|
||||
The directory name must match the `name` field.
|
||||
- `description`: Primary triggering mechanism. Include what the skill does AND
|
||||
specific triggers/contexts. All "when to use" info goes here (not in body).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: plan-sprint
|
||||
name: sprint-plan
|
||||
description: >
|
||||
Plan the next sprint and generate team briefing files. Use when the user says
|
||||
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
|
||||
or invokes /plan-sprint. Gathers current sprint status, scans the backlog,
|
||||
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
|
||||
proposes ticket selection, and writes briefing files per team to
|
||||
docs/sprints/sprint-N/.
|
||||
user-invocable: true
|
||||
@@ -19,8 +19,8 @@ When this skill is invoked, spawn SI using the Task tool:
|
||||
```
|
||||
Task(
|
||||
subagent_type: "si",
|
||||
prompt: "Run /plan-sprint for Sprint N. Read the skill at
|
||||
.claude/skills/plan-sprint/SKILL.md for the full workflow,
|
||||
prompt: "Run /sprint-plan for Sprint N. Read the skill at
|
||||
.claude/skills/sprint-plan/SKILL.md for the full workflow,
|
||||
then execute it. Use the arguments provided: {args}",
|
||||
description: "Plan sprint N"
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
---
|
||||
name: sprint-start
|
||||
description: >
|
||||
Manage the sprint lifecycle from main, or start sprint work on a team
|
||||
branch. Use when the user says "start sprint", "start working on the
|
||||
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
|
||||
assesses sprint state and does the next right thing (close, activate, or
|
||||
guide). On a team branch: merges main, loads the briefing, presents the
|
||||
work plan.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
|
||||
---
|
||||
|
||||
# Start Sprint Skill
|
||||
|
||||
Prepare a team branch for sprint work: sync with main, load the sprint
|
||||
briefing, and present actionable next steps.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the team
|
||||
|
||||
The current branch IS the team. Read it with:
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||
|
||||
If on `main`, follow the **Main branch workflow** below instead of
|
||||
the team branch workflow (steps 2–8).
|
||||
|
||||
---
|
||||
|
||||
## Main branch workflow (sprint lifecycle management)
|
||||
|
||||
When `/sprint-start` is run on `main`, assess the current sprint state
|
||||
and do the next right thing. Query the database to determine the state:
|
||||
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
```
|
||||
|
||||
Then follow the **first matching case**:
|
||||
|
||||
### Case A: An active sprint exists
|
||||
|
||||
The active sprint needs to be closed before moving on.
|
||||
|
||||
#### A1. Close the active sprint
|
||||
|
||||
```bash
|
||||
db/connectors/sprint stop
|
||||
```
|
||||
|
||||
This marks the active sprint as completed and lists carry-over candidates.
|
||||
Note the sprint number (N) from the output.
|
||||
|
||||
#### A2. Bump the version
|
||||
|
||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
||||
sprint N, the version is `v0.1.N`.
|
||||
|
||||
Update `project.yaml`:
|
||||
- Set the `version` field to `0.1.N` (this is the source of truth).
|
||||
|
||||
Update `server/Cargo.toml`:
|
||||
- Set `version = "0.1.N"` in `[package]`.
|
||||
|
||||
Update `CHANGELOG.md`:
|
||||
- Move all entries under `## [Unreleased]` into a new section
|
||||
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
|
||||
- Leave `## [Unreleased]` as an empty section above the new version.
|
||||
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
|
||||
only move entries that have content.
|
||||
|
||||
#### A3. Commit the release
|
||||
|
||||
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
|
||||
```
|
||||
chore(meta): release v0.1.N
|
||||
```
|
||||
|
||||
#### A4. Tag the release
|
||||
|
||||
```bash
|
||||
git tag v0.1.N
|
||||
```
|
||||
|
||||
#### A5. Push
|
||||
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
#### A6. Check for a planned sprint
|
||||
|
||||
After closing, re-query the database. If a sprint in `planning` status
|
||||
exists, continue to **Case B**. Otherwise, report the close and suggest
|
||||
running `/sprint-plan`.
|
||||
|
||||
---
|
||||
|
||||
### Case B: No active sprint, but a planned sprint exists
|
||||
|
||||
A sprint is ready to activate. Verify it looks complete:
|
||||
|
||||
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
|
||||
```bash
|
||||
ls docs/sprints/sprint-N/
|
||||
```
|
||||
2. Check the ticket count:
|
||||
```bash
|
||||
db/connectors/sprint status --sprint N
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
and suggest running `/sprint-plan` to complete planning.
|
||||
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start
|
||||
```
|
||||
|
||||
Then report:
|
||||
- Sprint activated (name, ticket count per team)
|
||||
- Remind the user to switch to a team branch and run `/sprint-start`
|
||||
there (or `cd` into the relevant worktree)
|
||||
|
||||
---
|
||||
|
||||
### Case C: No active sprint and no planned sprint
|
||||
|
||||
Nothing is ready. Report the state and suggest running `/sprint-plan`
|
||||
to plan the next sprint.
|
||||
|
||||
---
|
||||
|
||||
## Team branch workflow
|
||||
|
||||
### 2. Sync with main
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git merge origin/main --no-edit
|
||||
```
|
||||
|
||||
If the merge has conflicts, report them and stop — do not force-resolve.
|
||||
|
||||
### 3. Load sprint context
|
||||
|
||||
Run the sprint CLI to get the full context dump in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start-work
|
||||
```
|
||||
|
||||
This auto-detects the active sprint and current team from the branch.
|
||||
It outputs: sprint metadata, briefing paths, decision refs, actionable
|
||||
tickets, blocked tickets, and done tickets.
|
||||
|
||||
If no active sprint is found, report that and stop.
|
||||
|
||||
### 4. Read the sprint briefing
|
||||
|
||||
Read the briefing file(s) listed in the `start-work` output
|
||||
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
|
||||
If no matching briefing exists for the team, suggest running
|
||||
`/sprint-plan` to generate one.
|
||||
|
||||
### 5. Load ticket details
|
||||
|
||||
For tickets that need more detail than the `start-work` summary provides:
|
||||
```bash
|
||||
db/connectors/ticket show <id>
|
||||
```
|
||||
|
||||
### 6. Read key decisions
|
||||
|
||||
Read the decision files referenced in the sprint briefing so the agent has
|
||||
full architectural context before starting work.
|
||||
|
||||
### 7. Mark tickets in progress and present the work plan
|
||||
|
||||
Mark all actionable (unblocked, non-done) tickets in the sprint as
|
||||
`in_progress`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> in_progress
|
||||
```
|
||||
|
||||
Then output a summary:
|
||||
- Sprint name and goal
|
||||
- Branch status (clean merge or conflicts)
|
||||
- Tickets marked in_progress (list with IDs)
|
||||
- Blocked tickets (and what blocks them)
|
||||
- Key decisions loaded
|
||||
- Suggested first task (lowest ID unblocked ticket)
|
||||
|
||||
### 8. Confirm and spawn the team
|
||||
|
||||
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
|
||||
agent lineup with the user. If declined, stop.
|
||||
|
||||
Once confirmed:
|
||||
|
||||
#### 8a. Parse agents from the briefing
|
||||
|
||||
Extract agent names from the `**Agents:**` line. Format:
|
||||
```
|
||||
**Agents:** Name (role), Name (role), ...
|
||||
```
|
||||
|
||||
Map each name to its `subagent_type` (lowercase):
|
||||
- "Dudley (simulation)" → `dudley`
|
||||
- "Stig (UI)" → `stig`
|
||||
- "Tyre (architecture)" → `tyre`
|
||||
- "Hoshe (QA)" → `hoshe`
|
||||
- "Mellanie (author)" → `mellanie`
|
||||
- etc. (see `.claude/agents/` for full roster)
|
||||
|
||||
#### 8b. Create the team
|
||||
|
||||
```
|
||||
TeamCreate(team_name: "sprint-{N}-{team}")
|
||||
```
|
||||
|
||||
This makes you the team lead.
|
||||
|
||||
#### 8c. Create tasks from tickets
|
||||
|
||||
For each ticket in the briefing, create a task:
|
||||
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "#{id}: {title}",
|
||||
description: "Full ticket details from step 5, plus briefing notes
|
||||
and integration points for this ticket.",
|
||||
activeForm: "Working on #{id}: {short_title}"
|
||||
)
|
||||
```
|
||||
|
||||
After creating all tasks, mirror the dependency chain from the briefing
|
||||
using `TaskUpdate` with `addBlockedBy`.
|
||||
|
||||
#### 8d. Spawn agents
|
||||
|
||||
For each agent from the `**Agents:**` line, spawn a teammate in the
|
||||
background. Spawn all agents in parallel (one message, multiple Task calls):
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "{name_lowercase}",
|
||||
team_name: "sprint-{N}-{team}",
|
||||
name: "{name_lowercase}",
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||
mark it in_progress, and implement it.
|
||||
5. When done, mark the task completed and check TaskList for
|
||||
the next available task.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
run_in_background: true
|
||||
)
|
||||
```
|
||||
|
||||
#### 8e. Report
|
||||
|
||||
Output to the user:
|
||||
- Team name: `sprint-{N}-{team}`
|
||||
- Agents spawned (names and roles)
|
||||
- Tasks created (count actionable vs blocked)
|
||||
- How to interact: `SendMessage` to talk to agents, `TaskList` to
|
||||
check progress
|
||||
|
||||
You are now the team lead. Agents work autonomously — monitor via
|
||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||
they arise.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: render-sprite
|
||||
name: sprite-gen
|
||||
description: >
|
||||
Render a 3D model to 2D sprites via the Godot render pipeline. Produces
|
||||
sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north,
|
||||
east, south, west) with outline applied at working resolution. Use when the
|
||||
user says "render sprite", "render model", "run the render pipeline",
|
||||
"test the pipeline", "/render-sprite", or asks to render a specific model
|
||||
"test the pipeline", "/sprite-gen", or asks to render a specific model
|
||||
(e.g., "render wall_structural"). Output: 12 PNG files in renderer/output/.
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ description: >
|
||||
Run the render script with the model name:
|
||||
|
||||
```bash
|
||||
.claude/skills/render-sprite/scripts/render.sh <model_name>
|
||||
.claude/skills/sprite-gen/scripts/render.sh <model_name>
|
||||
```
|
||||
|
||||
### Available Models
|
||||
@@ -52,4 +52,4 @@ ls renderer/models/*.tscn | xargs -I{} basename {} .tscn
|
||||
|
||||
1. Create model scene at `renderer/models/<name>.tscn`
|
||||
2. Apply texture from `renderer/textures/` via StandardMaterial3D
|
||||
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
|
||||
3. Run: `.claude/skills/sprite-gen/scripts/render.sh <name>`
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: start-workshop
|
||||
name: workshop-start
|
||||
description: >
|
||||
Start a multi-agent design workshop from a workshop brief. Use when the user says
|
||||
"start workshop", "run workshop", "let's start the workshop", or invokes /start-workshop.
|
||||
"start workshop", "run workshop", "let's start the workshop", or invokes /workshop-start.
|
||||
Parses the workshop brief to extract participants, questions, and round format.
|
||||
Creates a team, tasks, and spawns agents as teammates via the Task tool.
|
||||
---
|
||||
@@ -73,7 +73,7 @@ Cross-reference open PR head branches with the ahead-of-main branches.
|
||||
|
||||
Show a summary table of branches ahead of main. For each branch, indicate:
|
||||
- `[PR]` if it has an open pull request — warn that it should go through
|
||||
normal review channels (use `/review-pr` instead)
|
||||
normal review channels (use `/pr-review` instead)
|
||||
- Commit count and summary
|
||||
|
||||
Use `AskUserQuestion` to let the user pick which branches to merge.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Build and cache
|
||||
.cache/
|
||||
.tmp/
|
||||
server/target/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
tooling/test-client/target/
|
||||
content-ron/
|
||||
|
||||
# Godot client (further ignores managed by client team)
|
||||
|
||||
+89
-2
@@ -6,12 +6,99 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
|
||||
## [v0.1.11] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- Sprint 12 "Build" briefings — 50 tickets across server, client, copy, visual, ci teams; production-layer foundations + all v0.1 copy authoring
|
||||
- `.tmp/` gitignored repo directory for agent temp files — avoids Bash permission prompts during PR review comment posting
|
||||
- `sed -n` blanket permission in shared settings
|
||||
|
||||
### Changed
|
||||
- All skills renamed to domain-action convention (e.g. `commit`→`git-commit`, `review-pr`→`pr-review`, `gen-audio`→`audio-gen`, `render-sprite`→`sprite-gen`) — 12 renames total
|
||||
- `pr-review` skill uses Write tool into `.tmp/` instead of Bash heredocs to `/tmp/`
|
||||
|
||||
## [v0.1.10] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth
|
||||
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
|
||||
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
|
||||
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
|
||||
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
|
||||
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
|
||||
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
|
||||
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
|
||||
- 4 ambient zone loops: station base, workplace, bar, corridor — SAO-generated organic soundscape with crossfade loop points (#327)
|
||||
- 2 footstep SFX: metal walk and run — SAO hybrid with best-transient extraction (#327)
|
||||
- `audio-batch` command — batch audio generation from JSON manifests, supports SAO and harmonic synthesis, with `--dry-run`, `--only`, and `--skip-existing` flags
|
||||
- `--post` and `--output-ogg` flags on `audio-generate` — chain post-processing (trim, normalize, convert) into a single command
|
||||
|
||||
### Changed
|
||||
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
|
||||
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
|
||||
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
|
||||
- World radial menu uses theme font instead of ThemeDB.fallback_font
|
||||
- Monologue chimes replaced with production-quality manual synthesis — insert-tech aesthetic per D-074, pure sine harmonics with mathematical envelopes (#327)
|
||||
|
||||
### Fixed
|
||||
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
|
||||
|
||||
## [v0.1.9] — 2026-02-18
|
||||
|
||||
### Fixed
|
||||
- `make game` now builds client before launching — was missing `build-client` dependency, causing class_name registration failures after `make clean`
|
||||
- `build-client` uses `--import --quit` instead of just `--quit` — ensures `.godot/` cache and `global_script_class_cache.cfg` are created from scratch
|
||||
- `make clean` preserves `client/.godot/` directory (clears contents only) to avoid Godot startup issues
|
||||
|
||||
### Added
|
||||
- `--description TEXT` flag for `ticket create` CLI — previously required raw SQL workaround to set ticket descriptions
|
||||
|
||||
### Changed
|
||||
- UI audio assets revised — monologue chimes re-generated (0.8s, insert-tech aesthetic), fog_recognition re-generated (was silent), all 8 assets normalized to 44.1kHz stereo LUFS -16 (#453)
|
||||
- review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE
|
||||
|
||||
### Added
|
||||
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
|
||||
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
|
||||
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
|
||||
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
|
||||
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
|
||||
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
|
||||
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
|
||||
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
|
||||
- Domain-action naming convention for skills documented in create-skill guide
|
||||
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
|
||||
- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values
|
||||
- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted)
|
||||
- Reset plate StableId verification in `stable_id_ranges_match_spec`
|
||||
- Runtime content test (`content_runtime.rs`) separated from structural loading tests
|
||||
- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index)
|
||||
- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal)
|
||||
- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes
|
||||
|
||||
### Changed
|
||||
- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`)
|
||||
- `room_at()` documented with z-range and corridor overlap assumptions
|
||||
- TCP runtime test now has 10s read timeout and registers player in EntityRegistry
|
||||
|
||||
### Removed
|
||||
- Dead `RoomMember` component from reset.rs (defined but never used)
|
||||
- Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028
|
||||
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
|
||||
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
|
||||
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
|
||||
- Weapon aim lock audio (`sfx_weapon_aim_lock.ogg`) — clinical targeting confirmation tone for weapon aim state (#440)
|
||||
- Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440)
|
||||
|
||||
### Fixed
|
||||
- MessagePack int_64 encoder dead code branch (#516) — `-(1 << 63)` overflowed making int_64 branch unreachable; negative values beyond int_32 now correctly encode as 0xd3 instead of 0xcf
|
||||
- Cross-encoder fixture pipeline hardened — encode failures now exit non-zero instead of writing empty .msgpack files (#475 review)
|
||||
- GDScript fixture test no longer silently skips on missing/empty fixture dir — asserts instead (#475 review)
|
||||
- GDScript fixture generator covers all PlayerAction variants (added MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway)
|
||||
- `make pre-pr` now checks GDScript fixture staleness alongside Rust fixtures
|
||||
- Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch
|
||||
- Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb }
|
||||
- Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode
|
||||
- `make pre-pr` target — full pre-PR verification chain: lint → build → test → content validation → fixture staleness (#460, #465)
|
||||
- Branch-specific pre-PR variants: `make pre-pr-server`, `make pre-pr-client`, `make pre-pr-content`
|
||||
- Content cross-reference validation (9 checks) — canonical_id uniqueness, relationship targets, location slugs, dialogue locations, fact_ids, triangle membership, npc_count, dialogue line_ids, bidirectional relationships (#464)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
||||
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository name:** commonwealth (historical code name, retained for path stability)
|
||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
pre-pr-server pre-pr-client pre-pr-content
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -37,6 +40,12 @@ help:
|
||||
@echo " make validate-content Validate content YAML against schemas"
|
||||
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
|
||||
@echo " make content-ron Convert content YAML to RON (build-time)"
|
||||
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
|
||||
@echo " make golden-diff Show diff if golden file output has changed"
|
||||
@echo " make golden-update Regenerate golden file and stage for commit"
|
||||
@echo " make checklist-validate Validate checklist YAML against schema"
|
||||
@echo " make checklist-generate Validate checklists + print condition summary"
|
||||
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
||||
@echo ""
|
||||
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
|
||||
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||
@@ -63,6 +72,8 @@ setup-tooling:
|
||||
@command -v python3 >/dev/null 2>&1 || { echo "Install Python 3"; exit 1; }
|
||||
@command -v curl >/dev/null 2>&1 || { echo "Install curl (required for Godot download)"; exit 1; }
|
||||
@command -v unzip >/dev/null 2>&1 || { echo "Install unzip (required for Godot download)"; exit 1; }
|
||||
@python3 -c "import yaml" 2>/dev/null || { echo "Install PyYAML: pip install pyyaml"; exit 1; }
|
||||
@python3 -c "import jsonschema" 2>/dev/null || { echo "Install jsonschema: pip install jsonschema"; exit 1; }
|
||||
|
||||
setup-hooks:
|
||||
@git config core.hooksPath .config/hooks
|
||||
@@ -77,7 +88,7 @@ build-server:
|
||||
|
||||
build-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client --quit
|
||||
$(GODOT) --headless --path client --import --quit
|
||||
|
||||
# --- Run ---
|
||||
|
||||
@@ -88,7 +99,7 @@ client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --path client
|
||||
|
||||
game: stop build-server
|
||||
game: stop build
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Starting server..."
|
||||
@cd server && cargo run &
|
||||
@@ -111,6 +122,41 @@ test-server:
|
||||
fixtures:
|
||||
cd server && cargo test --test gen_fixtures -- --ignored
|
||||
|
||||
fixtures-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Generating GDScript fixtures for Rust decoder..."
|
||||
$(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd
|
||||
@echo "Verifying Rust can decode GDScript fixtures..."
|
||||
cd server && cargo test --test serialization gdscript_generated_fixtures
|
||||
@echo "--- Fixtures-client: PASS ---"
|
||||
|
||||
golden-diff:
|
||||
@echo "Regenerating golden output for comparison..."
|
||||
@mkdir -p .cache/golden
|
||||
@cp server/tests/golden/proof_room_tick_10.json .cache/golden/before.json
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden; \
|
||||
rc=$$?; \
|
||||
cp ../server/tests/golden/proof_room_tick_10.json ../.cache/golden/after.json 2>/dev/null; \
|
||||
cp ../.cache/golden/before.json ../server/tests/golden/proof_room_tick_10.json; \
|
||||
if [ $$rc -ne 0 ]; then echo "Cargo test failed (golden file restored)."; exit $$rc; fi
|
||||
@if diff -q .cache/golden/before.json .cache/golden/after.json >/dev/null 2>&1; then \
|
||||
echo "--- Golden file: UP TO DATE ---"; \
|
||||
else \
|
||||
echo "--- Golden file has CHANGED ---"; \
|
||||
echo ""; \
|
||||
diff --color -u .cache/golden/before.json .cache/golden/after.json || true; \
|
||||
echo ""; \
|
||||
echo "Run 'make golden-update' to accept changes."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
golden-update:
|
||||
@echo "Regenerating golden file..."
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
|
||||
@git add server/tests/golden/
|
||||
@echo "--- Golden file updated and staged ---"
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
|
||||
test-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
@@ -148,13 +194,13 @@ pre-pr-validate: validate-content check-fact-ids
|
||||
@echo "--- Content validation: PASS ---"
|
||||
|
||||
pre-pr-fixtures:
|
||||
@echo "Checking fixture staleness..."
|
||||
@echo "Checking Rust->GDScript fixture staleness..."
|
||||
@cd server && cargo test --test gen_fixtures -- --ignored
|
||||
@if git diff --quiet client/tests/fixtures/; then \
|
||||
echo "--- Fixtures: UP TO DATE ---"; \
|
||||
echo "--- Rust fixtures: UP TO DATE ---"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "--- FIXTURES STALE ---"; \
|
||||
echo "--- RUST FIXTURES STALE ---"; \
|
||||
echo " Protocol changed but fixtures not regenerated."; \
|
||||
echo " Stale fixtures make all client tests FALSE POSITIVES."; \
|
||||
echo ""; \
|
||||
@@ -164,6 +210,22 @@ pre-pr-fixtures:
|
||||
echo " Fix: commit the updated fixtures with your protocol change."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Checking GDScript->Rust fixture staleness..."
|
||||
@$(MAKE) fixtures-client
|
||||
@if git diff --quiet server/tests/fixtures/gdscript/; then \
|
||||
echo "--- GDScript fixtures: UP TO DATE ---"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "--- GDSCRIPT FIXTURES STALE ---"; \
|
||||
echo " Protocol changed but GDScript fixtures not regenerated."; \
|
||||
echo " Stale fixtures make cross-encoder tests FALSE POSITIVES."; \
|
||||
echo ""; \
|
||||
echo " Changed files:"; \
|
||||
git diff --stat server/tests/fixtures/gdscript/; \
|
||||
echo ""; \
|
||||
echo " Fix: commit the updated fixtures with your protocol change."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Branch-specific variants (faster, scope-appropriate)
|
||||
|
||||
@@ -173,7 +235,7 @@ pre-pr-server: lint-server build-server test-server pre-pr-fixtures
|
||||
pre-pr-client: lint-client build-client test-client
|
||||
@echo "=== Client pre-PR: PASSED ==="
|
||||
|
||||
pre-pr-content: validate-content check-fact-ids
|
||||
pre-pr-content: validate-content check-fact-ids checklist-validate
|
||||
@echo "=== Content pre-PR: PASSED ==="
|
||||
|
||||
# --- CI (run locally) ---
|
||||
@@ -214,6 +276,15 @@ validate-content:
|
||||
check-fact-ids:
|
||||
@tooling/check-fact-ids
|
||||
|
||||
checklist-validate:
|
||||
@tooling/validate-checklist --check
|
||||
|
||||
checklist-generate:
|
||||
@tooling/validate-checklist
|
||||
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
@@ -223,5 +294,5 @@ content-ron:
|
||||
clean:
|
||||
cd server && cargo clean || true
|
||||
rm -rf .cache/*
|
||||
rm -rf client/.godot client/reports
|
||||
rm -rf client/.godot/* client/reports
|
||||
@echo "Clean complete."
|
||||
|
||||
@@ -87,7 +87,8 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
|
||||
elif 0 <= value and value <= (1 << 32) - 1:
|
||||
buffer.put_u8(types["uint_32"])
|
||||
buffer.put_u32(value)
|
||||
elif - (1 << 63) <= value and value < (1 << 63):
|
||||
elif value < 0:
|
||||
# Negative beyond int_32 range — encode as int_64 (0xd3)
|
||||
buffer.put_u8(types["int_64"])
|
||||
buffer.put_64(value)
|
||||
else:
|
||||
@@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
for obj in value:
|
||||
_encode_message(buffer, obj)
|
||||
var inner_err = _encode_message(buffer, obj)
|
||||
if inner_err:
|
||||
return inner_err
|
||||
|
||||
TYPE_DICTIONARY:
|
||||
var size = value.size()
|
||||
@@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
for key in value:
|
||||
_encode_message(buffer, key)
|
||||
_encode_message(buffer, value[key])
|
||||
var key_err = _encode_message(buffer, key)
|
||||
if key_err:
|
||||
return key_err
|
||||
var val_err = _encode_message(buffer, value[key])
|
||||
if val_err:
|
||||
return val_err
|
||||
|
||||
TYPE_PACKED_BYTE_ARRAY:
|
||||
var size = value.size()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://uflbvtm1aquk"
|
||||
path="res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/cursor_hover.ogg"
|
||||
dest_files=["res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://bw0mfil2343po"
|
||||
path="res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/fog_recognition.ogg"
|
||||
dest_files=["res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dei8ikwaqyn67"
|
||||
path="res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/implant_open.ogg"
|
||||
dest_files=["res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://b7iopmg6ftp4k"
|
||||
path="res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_monologue_chime.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://4m87qby6qdhc"
|
||||
path="res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_monologue_chime_urgent.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dbu2lr4scswyn"
|
||||
path="res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_stance_change.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://c5lduxqgx0vme"
|
||||
path="res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_weapon_aim_lock.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://c2bqso5hkljq1"
|
||||
path="res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/weapon_aim.ogg"
|
||||
dest_files=["res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,8 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
|
||||
|
||||
[resource]
|
||||
base_font = ExtResource("1_base")
|
||||
spacing_glyph = 1
|
||||
spacing_space = 1
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_resource type="Theme" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_font")
|
||||
default_font_size = 14
|
||||
|
||||
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
Label/font_sizes/font_size = 14
|
||||
|
||||
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
|
||||
RichTextLabel/font_sizes/normal_font_size = 14
|
||||
|
||||
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
|
||||
Button/font_sizes/font_size = 14
|
||||
|
||||
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
LineEdit/font_sizes/font_size = 14
|
||||
@@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="res://assets/theme/game_theme.tres"
|
||||
theme/custom_font="res://assets/fonts/michroma_ui.tres"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1920
|
||||
@@ -106,6 +111,16 @@ stance_down={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
bug_report={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
+13
-2
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=17 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -16,6 +16,9 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
|
||||
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[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"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -131,6 +134,12 @@ layer = 20
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
|
||||
|
||||
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
|
||||
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
|
||||
|
||||
; #503: Auto-checklist overlay — condition progress in gauntlet mode
|
||||
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
|
||||
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
@@ -140,6 +149,8 @@ script = ExtResource("10_cursor")
|
||||
|
||||
; --- Modal layer (CanvasLayer 30) ---
|
||||
; Full-screen overlays: pause menu, inventory modal, death screen.
|
||||
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
|
||||
[node name="ModalLayer" type="CanvasLayer" parent="."]
|
||||
layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdkut8u7sxwmb
|
||||
@@ -0,0 +1 @@
|
||||
uid://de816lfn5xeqs
|
||||
@@ -36,6 +36,21 @@ var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, option
|
||||
# InputMapper suppresses movement when this is true.
|
||||
var dialogue_active: bool = false
|
||||
|
||||
# v8 fields (#496): Gauntlet mode — room timer + personal bests
|
||||
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
|
||||
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
|
||||
|
||||
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
|
||||
# Cursor shape changes still fire when false (D-056 option a).
|
||||
# v0.1 assumption: always true — both playable characters (detective and smuggler)
|
||||
# have neural inserts. Future characters without inserts would receive false from
|
||||
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||
var insert_active: bool = true
|
||||
|
||||
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
@@ -118,6 +133,30 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
else:
|
||||
gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
room_id = snapshot.room_id
|
||||
else:
|
||||
room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
|
||||
# Server may send false for characters without an insert in future sprints.
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
insert_active = snapshot.insert_active
|
||||
else:
|
||||
insert_active = true
|
||||
|
||||
# #507: rng_seed — server sends current RNG seed for replay determinism.
|
||||
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
|
||||
if snapshot.has("rng_seed"):
|
||||
rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
rng_seed = null
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
|
||||
@@ -3,7 +3,11 @@ extends Node
|
||||
# Semantic actions — NO raw key codes cross the bridge
|
||||
# Movement uses hold-to-move (polled each frame in _process).
|
||||
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
|
||||
# Composite diagonals: holding W+D simultaneously → northeast.
|
||||
#
|
||||
# D-054: Mouse-relative facing and movement.
|
||||
# Mouse position determines facing direction (client-side float).
|
||||
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
|
||||
# Server receives facing octant only — the full float stays client-side.
|
||||
#
|
||||
# Movement throttle: client-side rate limit per stance (D-053).
|
||||
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
|
||||
@@ -14,10 +18,19 @@ enum Action {
|
||||
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
@@ -30,27 +43,44 @@ var _last_move_msec: int = 0
|
||||
|
||||
|
||||
# Hold-to-move: poll held direction keys each frame, throttled by stance.
|
||||
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
|
||||
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
|
||||
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
|
||||
func _process(_delta: float) -> void:
|
||||
# D-054: Update facing angle from mouse position every frame
|
||||
_update_facing_from_mouse()
|
||||
|
||||
if GameState.dialogue_active:
|
||||
return
|
||||
var dir := Vector2i.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
dir.y -= 1
|
||||
if Input.is_action_pressed("move_south"):
|
||||
dir.y += 1
|
||||
if Input.is_action_pressed("move_east"):
|
||||
dir.x += 1
|
||||
if Input.is_action_pressed("move_west"):
|
||||
dir.x -= 1
|
||||
|
||||
if dir != Vector2i.ZERO:
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
if facing_octant != _last_sent_octant:
|
||||
_last_sent_octant = facing_octant
|
||||
input_queue.append({
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
})
|
||||
|
||||
# Poll held WASD keys
|
||||
var raw_dir := Vector2i.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
raw_dir.y -= 1
|
||||
if Input.is_action_pressed("move_south"):
|
||||
raw_dir.y += 1
|
||||
if Input.is_action_pressed("move_east"):
|
||||
raw_dir.x += 1
|
||||
if Input.is_action_pressed("move_west"):
|
||||
raw_dir.x -= 1
|
||||
|
||||
if raw_dir != Vector2i.ZERO:
|
||||
var now := Time.get_ticks_msec()
|
||||
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
|
||||
if now - _last_move_msec >= interval:
|
||||
_last_move_msec = now
|
||||
var action: Action = _dir_to_action(dir)
|
||||
# D-054: Transform WASD input relative to mouse facing
|
||||
var world_dir := _wasd_to_world_dir(raw_dir)
|
||||
var action: Action = _dir_to_action(world_dir)
|
||||
input_queue.append({
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
@@ -74,6 +104,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_UP
|
||||
elif event.is_action_pressed("stance_down"):
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
@@ -89,6 +124,85 @@ func flush_queue() -> Array[Dictionary]:
|
||||
return queue
|
||||
|
||||
|
||||
## Reset facing state to default (North). Use in tests per D-030 testability.
|
||||
func reset_facing_state() -> void:
|
||||
facing_angle = -PI / 2.0
|
||||
facing_octant = "North"
|
||||
_last_sent_octant = "North"
|
||||
|
||||
|
||||
# D-054: Compute facing angle from mouse position relative to player screen position.
|
||||
# Uses viewport canvas transform to convert world coords to screen coords.
|
||||
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
|
||||
# autoload that runs before game loop rendering, so position is always current-tick.
|
||||
func _update_facing_from_mouse() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_world_px := GameState.player_position * Constants.TILE_SIZE
|
||||
var player_screen := canvas_xf * player_world_px
|
||||
var mouse_screen := vp.get_mouse_position()
|
||||
var delta := mouse_screen - player_screen
|
||||
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
|
||||
if delta.length_squared() > 4.0:
|
||||
facing_angle = delta.angle()
|
||||
facing_octant = _angle_to_octant(facing_angle)
|
||||
|
||||
|
||||
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
|
||||
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
|
||||
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
|
||||
# Forward = facing_angle direction. Output: nearest octant direction vector.
|
||||
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
|
||||
# Build a continuous direction vector relative to facing.
|
||||
# raw_dir.y: -1 = W (forward), +1 = S (backward)
|
||||
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
|
||||
var forward := Vector2(cos(facing_angle), sin(facing_angle))
|
||||
var right := Vector2(-forward.y, forward.x) # 90° clockwise
|
||||
|
||||
# Combine: forward/back from W/S, strafe from A/D
|
||||
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
|
||||
|
||||
# Snap to nearest octant direction
|
||||
return _snap_to_octant_dir(world_float)
|
||||
|
||||
|
||||
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
|
||||
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
if dir.length_squared() < 0.001:
|
||||
return Vector2i.ZERO
|
||||
var angle := dir.angle()
|
||||
# Quantize to nearest 45° (PI/4)
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return Vector2i(1, 0) # East
|
||||
1: return Vector2i(1, 1) # Southeast
|
||||
2, -6: return Vector2i(0, 1) # South
|
||||
3, -5: return Vector2i(-1, 1) # Southwest
|
||||
4, -4: return Vector2i(-1, 0) # West
|
||||
-3, 5: return Vector2i(-1, -1) # Northwest
|
||||
-2: return Vector2i(0, -1) # North
|
||||
-1: return Vector2i(1, -1) # Northeast
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
# D-054: Convert a facing angle (radians) to the nearest octant name.
|
||||
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
|
||||
static func _angle_to_octant(angle: float) -> String:
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return "East"
|
||||
1: return "Southeast"
|
||||
2, -6: return "South"
|
||||
3, -5: return "Southwest"
|
||||
4, -4: return "West"
|
||||
-3, 5: return "Northwest"
|
||||
-2: return "North"
|
||||
-1: return "Northeast"
|
||||
_: return "East"
|
||||
|
||||
|
||||
# Map a direction vector to the corresponding movement Action.
|
||||
# Handles all 8 directions via composite W+D, W+A, etc.
|
||||
static func _dir_to_action(dir: Vector2i) -> Action:
|
||||
|
||||
@@ -10,6 +10,8 @@ var _test_player_pos: Vector2i = Vector2i(10, 10)
|
||||
var _test_facing: String = "North"
|
||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -40,6 +42,8 @@ func reset_test_state() -> void:
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
_test_gauntlet_mode = false
|
||||
_test_npc_relationship = "Unknown"
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -175,13 +179,22 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
return ERR_CONNECTION_ERROR
|
||||
if test_mode:
|
||||
var action: int = player_input.get("action", -1)
|
||||
var wire_name: String = _action_enum_to_wire(action)
|
||||
var wire_name: String = action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
_test_input_queue.append(wire_name)
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
var facing: String = ""
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
if not facing.is_empty():
|
||||
_test_facing = facing
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
return OK
|
||||
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
# _action_enum_to_wire already emits push_warning for invalid actions
|
||||
# action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
||||
@@ -238,7 +251,7 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func _action_enum_to_wire(action: int) -> String:
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
match action:
|
||||
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
|
||||
@@ -255,9 +268,13 @@ static func _action_enum_to_wire(action: int) -> String:
|
||||
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
|
||||
InputMapper.Action.OPEN_MENU:
|
||||
# Client-only action, not part of wire protocol
|
||||
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
|
||||
return ""
|
||||
return "" # Client-only action, not part of wire protocol
|
||||
InputMapper.Action.BUG_REPORT:
|
||||
return "" # Client-only action (#495), not part of wire protocol
|
||||
InputMapper.Action.SET_FACING:
|
||||
return "SetFacing" # D-054: facing octant update (no movement)
|
||||
InputMapper.Action.TELEPORT_HUB:
|
||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
@@ -273,6 +290,11 @@ func _test_snapshot() -> Dictionary:
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
# #501: Reset to hub spawn position, clear dialogue
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
@@ -285,7 +307,6 @@ func _test_snapshot() -> Dictionary:
|
||||
if _test_is_walkable(new_pos):
|
||||
_test_player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
_test_facing = _delta_to_facing(delta)
|
||||
# Walk-away dismisses dialogue (D-064)
|
||||
if _test_in_dialogue:
|
||||
_test_in_dialogue = false
|
||||
@@ -316,6 +337,7 @@ func _test_snapshot() -> Dictionary:
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": _test_npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
@@ -394,6 +416,7 @@ func _test_snapshot() -> Dictionary:
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
extends RefCounted
|
||||
|
||||
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
|
||||
## checklist YAML conditions and latches satisfied conditions.
|
||||
##
|
||||
## Usage:
|
||||
## var evaluator := ChecklistEvaluator.new()
|
||||
## evaluator.load_room("inventory_warehouse")
|
||||
## evaluator.evaluate() # call each tick
|
||||
## var results := evaluator.get_results()
|
||||
##
|
||||
## Condition types (per checklist.schema.json):
|
||||
## player_near, player_facing, entity_present, entity_absent,
|
||||
## expected_monologue, expected_dialogue, expected_interaction_verb
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497).
|
||||
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _current_room_id: String = ""
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
# Content directory lives at repo root (content/), one level above the Godot
|
||||
# project (client/). In editor/dev mode we resolve via the project path.
|
||||
# In exported builds, content is expected at res://content/ (copied by export
|
||||
# preset) — the globalize fallback won't exist, so check res:// first.
|
||||
if DirAccess.dir_exists_absolute("res://content"):
|
||||
_content_base = ProjectSettings.globalize_path("res://content")
|
||||
else:
|
||||
var project_path := ProjectSettings.globalize_path("res://")
|
||||
_content_base = project_path.path_join("../content")
|
||||
|
||||
|
||||
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
|
||||
func load_room(room_id: String) -> void:
|
||||
if room_id == _current_room_id and _loaded:
|
||||
return
|
||||
|
||||
_current_room_id = room_id
|
||||
_room_conditions.clear()
|
||||
|
||||
# Clear per-room latches (keep cross-room latches)
|
||||
var cross_ids := {}
|
||||
for cond in _cross_conditions:
|
||||
cross_ids[cond.get("id", "")] = true
|
||||
var kept := {}
|
||||
for cid in _latched:
|
||||
if cross_ids.has(cid):
|
||||
kept[cid] = true
|
||||
_latched = kept
|
||||
|
||||
# Load per-room checklist
|
||||
var room_path := _content_base.path_join(
|
||||
"gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_data := _load_checklist_file(room_path)
|
||||
if room_data.has("conditions"):
|
||||
_room_conditions = room_data["conditions"]
|
||||
_warn_empty_ids(_room_conditions, room_path)
|
||||
|
||||
# Load cross-room checks (only on first load)
|
||||
if _cross_conditions.is_empty():
|
||||
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
|
||||
var cross_data := _load_checklist_file(cross_path)
|
||||
if cross_data.has("conditions"):
|
||||
_cross_conditions = cross_data["conditions"]
|
||||
_warn_empty_ids(_cross_conditions, cross_path)
|
||||
|
||||
_loaded = true
|
||||
|
||||
|
||||
## Evaluate all conditions against current GameState. Latches newly met conditions.
|
||||
func evaluate() -> void:
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty() or _latched.has(cid):
|
||||
continue
|
||||
if _evaluate_condition(cond):
|
||||
_latched[cid] = true
|
||||
|
||||
|
||||
## Returns array of {id, description, met} for all loaded conditions.
|
||||
## Conditions with empty id are excluded (invalid, cannot be latched).
|
||||
func get_results() -> Array:
|
||||
var results: Array = []
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty():
|
||||
continue
|
||||
results.append({
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
## Total number of loaded conditions (excludes conditions with empty id).
|
||||
func get_total_count() -> int:
|
||||
var count: int = 0
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
if not cond.get("id", "").is_empty():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
## Number of latched (met) conditions.
|
||||
func get_met_count() -> int:
|
||||
return _latched.size()
|
||||
|
||||
|
||||
## Whether all conditions are met.
|
||||
func is_complete() -> bool:
|
||||
return get_met_count() >= get_total_count() and get_total_count() > 0
|
||||
|
||||
|
||||
## Whether any checklist is loaded.
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Reset all state (room change to null, or disconnect).
|
||||
func reset() -> void:
|
||||
_room_conditions.clear()
|
||||
_cross_conditions.clear()
|
||||
_latched.clear()
|
||||
_current_room_id = ""
|
||||
_loaded = false
|
||||
|
||||
|
||||
static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||
for i in conditions.size():
|
||||
if conditions[i].get("id", "").is_empty():
|
||||
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool:
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
"player_facing":
|
||||
return _eval_player_facing(cond)
|
||||
"entity_present":
|
||||
return _eval_entity_present(cond)
|
||||
"entity_absent":
|
||||
return _eval_entity_absent(cond)
|
||||
"expected_monologue":
|
||||
return _eval_expected_monologue(cond)
|
||||
"expected_dialogue":
|
||||
return _eval_expected_dialogue(cond)
|
||||
"expected_interaction_verb":
|
||||
return _eval_expected_interaction_verb(cond)
|
||||
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
|
||||
return false
|
||||
|
||||
|
||||
## x/y and radius are in tile coordinates (matching GameState.player_position),
|
||||
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
|
||||
## happens only at render time.
|
||||
func _eval_player_near(cond: Dictionary) -> bool:
|
||||
var tx: float = float(cond.get("x", 0))
|
||||
var ty: float = float(cond.get("y", 0))
|
||||
var radius: float = float(cond.get("radius", 0.0))
|
||||
var target := Vector2(tx, ty)
|
||||
return GameState.player_position.distance_to(target) <= radius
|
||||
|
||||
|
||||
func _eval_player_facing(cond: Dictionary) -> bool:
|
||||
var direction: String = str(cond.get("direction", ""))
|
||||
# Schema uses 4-cardinal (North/South/East/West).
|
||||
# GameState uses 8-directional. Exact match only.
|
||||
return GameState.player_facing == direction
|
||||
|
||||
|
||||
func _eval_entity_present(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_entity_absent(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return not _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_expected_monologue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_monologue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_monologue.get("text", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_dialogue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_dialogue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_dialogue.get("speech", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
var verb: String = str(cond.get("verb", ""))
|
||||
for interaction in GameState.nearby_interactions:
|
||||
if not interaction is Dictionary:
|
||||
continue
|
||||
if int(interaction.get("entity_id", -1)) != entity_id:
|
||||
continue
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
for v in verbs:
|
||||
if not v is Dictionary:
|
||||
continue
|
||||
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
|
||||
if v.get("available", true):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _find_entity(entity_id: int) -> bool:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if int(entity.get("entity_id", -1)) == entity_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
#
|
||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("ChecklistEvaluator: cannot open %s" % path)
|
||||
return {}
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
@@ -59,13 +59,22 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# D-033 color lookup by entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Npc": return ENTITY_COLOR_UNKNOWN
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
|
||||
# D-048/D-056: Insert-styled UI color palette
|
||||
@@ -80,3 +89,10 @@ const PERIPHERAL_ALPHA: float = 0.5
|
||||
# Facing direction indicator
|
||||
const FACING_INDICATOR_SIZE: float = 6.0
|
||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
|
||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
|
||||
+124
-2
@@ -12,11 +12,17 @@ extends Node2D
|
||||
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
|
||||
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
@@ -49,13 +55,22 @@ func _ready() -> void:
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
var old_pos := GameState.player_position
|
||||
GameState.apply_snapshot(snapshot)
|
||||
|
||||
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
|
||||
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
|
||||
_teleport_transition()
|
||||
|
||||
# Late anchor: live mode — first snapshot arrives during _process.
|
||||
# Smoothing is already OFF (disabled in _ready), so setting
|
||||
# global_position takes effect immediately with no lerp.
|
||||
@@ -67,6 +82,16 @@ func _process(_delta: float) -> void:
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
@@ -88,6 +113,14 @@ func _process(_delta: float) -> void:
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -102,13 +135,26 @@ func _process(_delta: float) -> void:
|
||||
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
||||
# turn smoothing back on and sync its internal state so subsequent frames
|
||||
# get smooth camera tracking during gameplay.
|
||||
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
||||
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
||||
# re-enable block in the same _process() call undoing the snap.
|
||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
if _teleport_in_progress:
|
||||
_teleport_in_progress = false
|
||||
else:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
|
||||
# Send queued input to simulation
|
||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
|
||||
var inputs = InputMapper.flush_queue()
|
||||
for input in inputs:
|
||||
# #495: F12 WRONG button — client-only, trigger bug report capture
|
||||
if input.action == InputMapper.Action.BUG_REPORT:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
if input.action == InputMapper.Action.INTERACT:
|
||||
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
||||
var target_id: int = -1
|
||||
@@ -131,6 +177,18 @@ func _process(_delta: float) -> void:
|
||||
"verb": null,
|
||||
}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
|
||||
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
|
||||
# then clears the accumulator for the next tick.
|
||||
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
||||
bug_report_dialog.record_tick(
|
||||
GameState.current_tick,
|
||||
JSON.stringify(GameState.current_snapshot),
|
||||
_pending_record_inputs
|
||||
)
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
@@ -144,6 +202,10 @@ func _consume_monologue() -> void:
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
@@ -197,3 +259,63 @@ func _on_dialogue_dismissed() -> void:
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
|
||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||
func _teleport_transition() -> void:
|
||||
# Snap camera: disable smoothing, force re-anchor.
|
||||
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
||||
# re-enable block at the bottom of _process() doesn't undo the snap.
|
||||
camera.position_smoothing_enabled = false
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
_teleport_in_progress = true
|
||||
|
||||
# Clear client-side buffers
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
# Fade from black: instant black overlay, fades to transparent over 0.3s
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(0, 0, 0, 1.0)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 7
|
||||
const PROTOCOL_VERSION: int = 8
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -169,6 +169,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"options": dialogue_options,
|
||||
}
|
||||
|
||||
# v8: dialogue_response (#305, D-028) — NPC's spoken dialogue line from server
|
||||
# Separate from current_dialogue (client-side rich dialogue state).
|
||||
var dialogue_response: Variant = null
|
||||
var raw_dr: Variant = raw.get("dialogue_response")
|
||||
if raw_dr is Dictionary and raw_dr.has("text"):
|
||||
dialogue_response = {
|
||||
"line_id": str(raw_dr.get("line_id", "")),
|
||||
"text": str(raw_dr["text"]),
|
||||
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
||||
}
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -182,6 +193,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"nearby_interactions": nearby_interactions,
|
||||
"current_monologue": current_monologue,
|
||||
"current_dialogue": current_dialogue,
|
||||
"dialogue_response": dialogue_response,
|
||||
"pending_recognitions": pending_recognitions,
|
||||
}
|
||||
|
||||
@@ -293,13 +305,7 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
## action_data: null for unit variants, String for UsePerceptionMode
|
||||
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
|
||||
var action_value: Variant
|
||||
if action_data != null:
|
||||
# Data variant → single-element map
|
||||
action_value = { action_name: action_data }
|
||||
else:
|
||||
# Unit variant → bare string
|
||||
action_value = action_name
|
||||
var action_value: Variant = _encode_action(action_name, action_data)
|
||||
|
||||
var input := {
|
||||
"tick": tick,
|
||||
@@ -322,14 +328,9 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
var action_value: Variant
|
||||
if action_data != null:
|
||||
action_value = { action_name: action_data }
|
||||
else:
|
||||
action_value = action_name
|
||||
wire_inputs.append({
|
||||
"tick": input["tick"],
|
||||
"action": action_value,
|
||||
"action": _encode_action(action_name, action_data),
|
||||
})
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
@@ -340,6 +341,18 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode a PlayerAction for the wire.
|
||||
## Struct variants (Interact) always need their fields even when null.
|
||||
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
|
||||
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
|
||||
if action_data != null:
|
||||
return { action_name: action_data }
|
||||
# Interact is a struct variant — server expects named fields, not a bare string
|
||||
if action_name == "Interact":
|
||||
return { "Interact": { "target_entity_id": null, "verb": null } }
|
||||
return action_name
|
||||
|
||||
|
||||
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
|
||||
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
|
||||
static func decode_player_input(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
@@ -11,6 +11,9 @@ enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
signal state_changed(new_state: State)
|
||||
signal hovered_entity_changed(entity_id: int)
|
||||
@@ -305,8 +308,15 @@ func get_z_layer() -> int:
|
||||
|
||||
|
||||
func should_show_interactions() -> bool:
|
||||
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
|
||||
if not insert_active:
|
||||
return false
|
||||
return not weapon_mode_active or _shift_held
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
insert_active = active
|
||||
|
||||
|
||||
func get_interaction_range() -> int:
|
||||
return 2 # D-056: ~2 sim tiles
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkbfcalfo1kk8
|
||||
@@ -23,6 +23,11 @@ const LERP_SPEED: float = 12.0
|
||||
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
||||
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
|
||||
func _ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
@@ -40,6 +45,22 @@ func _process(delta: float) -> void:
|
||||
if not node.position.is_equal_approx(target):
|
||||
node.position = node.position.lerp(target, weight)
|
||||
|
||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
||||
var finished_ids: Array = []
|
||||
for entity_id in _entity_tweens.keys():
|
||||
if not entity_nodes.has(entity_id):
|
||||
finished_ids.append(entity_id)
|
||||
continue
|
||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||
tween_data.elapsed += delta
|
||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
||||
if t >= 1.0:
|
||||
finished_ids.append(entity_id)
|
||||
for eid in finished_ids:
|
||||
_entity_tweens.erase(eid)
|
||||
|
||||
|
||||
# Update entities from snapshot data
|
||||
func update_entities(entities: Array) -> void:
|
||||
@@ -75,12 +96,12 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
|
||||
# D-033 color by entity kind (Phase 1 default)
|
||||
# TODO(#361): derive from RelationshipState via knowledge graph
|
||||
# D-033 color by relationship (#521)
|
||||
entity_node.color = _color_for_kind(entity_data)
|
||||
|
||||
add_child(entity_node)
|
||||
entity_nodes[entity_id] = entity_node
|
||||
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
|
||||
|
||||
# Add facing indicator for the player entity
|
||||
if entity_id == GameState.player_entity_id:
|
||||
@@ -112,6 +133,22 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
||||
)
|
||||
|
||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
||||
if new_rel != old_rel:
|
||||
_entity_relationships[entity_id] = new_rel
|
||||
var new_color := _color_for_kind(entity_data)
|
||||
_entity_tweens[entity_id] = {
|
||||
"from": entity_node.color,
|
||||
"target": new_color,
|
||||
"elapsed": 0.0,
|
||||
}
|
||||
|
||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
||||
# are compositionally independent — both can change simultaneously without
|
||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
||||
|
||||
# v2: Peripheral vision dimming (D-015)
|
||||
# null visibility (v1 backward compat) defaults to full alpha
|
||||
var visibility: Variant = entity_data.get("visibility")
|
||||
@@ -119,11 +156,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
||||
entity_node.modulate.a = target_alpha
|
||||
|
||||
# v2: Update facing indicator rotation (player entity only)
|
||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||
if entity_id == GameState.player_entity_id:
|
||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
||||
if indicator != null:
|
||||
indicator.rotation = _facing_to_rotation(GameState.player_facing)
|
||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||
# Rotate from North basis: add PI/2 to convert.
|
||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||
|
||||
# Remove an entity node
|
||||
func _remove_entity_node(entity_id: int) -> void:
|
||||
@@ -134,6 +174,8 @@ func _remove_entity_node(entity_id: int) -> void:
|
||||
entity_node.queue_free()
|
||||
entity_nodes.erase(entity_id)
|
||||
_entity_targets.erase(entity_id)
|
||||
_entity_relationships.erase(entity_id)
|
||||
_entity_tweens.erase(entity_id)
|
||||
|
||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||
@@ -155,16 +197,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
||||
# Position at center of parent ColorRect — rotation around this point
|
||||
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
parent_node.add_child(indicator)
|
||||
|
||||
# Convert facing direction string to rotation in radians (0 = North/up)
|
||||
static func _facing_to_rotation(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return 0.0
|
||||
"Northeast": return PI / 4.0
|
||||
"East": return PI / 2.0
|
||||
"Southeast": return 3.0 * PI / 4.0
|
||||
"South": return PI
|
||||
"Southwest": return 5.0 * PI / 4.0
|
||||
"West": return 3.0 * PI / 2.0
|
||||
"Northwest": return 7.0 * PI / 4.0
|
||||
_: return 0.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw154tgjdyhe1
|
||||
@@ -0,0 +1 @@
|
||||
uid://dje4vilxh5xxu
|
||||
@@ -5,14 +5,15 @@ extends TileMapLayer
|
||||
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
|
||||
#
|
||||
# Tile types (atlas coords in the programmatic source):
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
|
||||
"wall": TileType.WALL,
|
||||
"door": TileType.DOOR,
|
||||
"object": TileType.OBJECT,
|
||||
"reset_plate": TileType.RESET_PLATE,
|
||||
}
|
||||
|
||||
var _initialized: bool = false
|
||||
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
|
||||
|
||||
# Create an atlas source backed by a programmatic image
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Floor (0,0) — dark gray
|
||||
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
|
||||
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
|
||||
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
|
||||
# Object (3,0) — teal
|
||||
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
|
||||
# Reset plate (4,0) — amber (#502)
|
||||
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create tile entries in the atlas
|
||||
for i in range(4):
|
||||
for i in range(5):
|
||||
source.create_tile(Vector2i(i, 0))
|
||||
|
||||
var source_id := ts.add_source(source)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcc1wwpk3moh1
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
## Generate MessagePack fixtures from GDScript encoder for Rust decoder testing.
|
||||
## Run with: make fixtures-client
|
||||
## (or: godot --headless --path client -s res://tests/gen_client_fixtures.gd)
|
||||
##
|
||||
## Reverse direction of server/tests/gen_fixtures.rs: GDScript encodes -> Rust decodes.
|
||||
## Together with the Rust-generated fixtures, this closes the cross-encoder
|
||||
## compatibility loop (D-030 Layer 1, #475).
|
||||
##
|
||||
## Loads Messagepack encoder directly (not via class_name) because -s scripts
|
||||
## run before the project's class_name registry is fully populated.
|
||||
extends SceneTree
|
||||
|
||||
var _Msgpack: GDScript
|
||||
var _count := 0
|
||||
var _errors := 0
|
||||
var _output_dir: String
|
||||
|
||||
|
||||
func _init():
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run():
|
||||
_Msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
# Resolve repo root from Godot project root (client/).
|
||||
# Assumes client/ is one level below repo root — validated below.
|
||||
var project_root := ProjectSettings.globalize_path("res://")
|
||||
var repo_root := project_root.rstrip("/").get_base_dir()
|
||||
_output_dir = repo_root.path_join("server/tests/fixtures/gdscript")
|
||||
|
||||
if not DirAccess.dir_exists_absolute(repo_root.path_join("server")):
|
||||
push_error("Repo root detection failed: %s/server/ does not exist" % repo_root)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(_output_dir)
|
||||
|
||||
_generate_inputs()
|
||||
_generate_boundary_inputs()
|
||||
_generate_batch()
|
||||
|
||||
if _errors > 0:
|
||||
push_error("FAILED: %d encode errors encountered" % _errors)
|
||||
quit(1)
|
||||
return
|
||||
if _count == 0:
|
||||
push_error("FAILED: no fixtures generated")
|
||||
quit(1)
|
||||
return
|
||||
print("Generated %d GDScript fixtures at %s" % [_count, _output_dir])
|
||||
quit()
|
||||
|
||||
|
||||
func _write_fixture(name: String, bytes: PackedByteArray) -> void:
|
||||
if bytes.is_empty():
|
||||
push_error("Encode produced empty bytes for fixture: %s" % name)
|
||||
_errors += 1
|
||||
return
|
||||
var path := _output_dir.path_join(name + ".msgpack")
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()])
|
||||
_errors += 1
|
||||
return
|
||||
file.store_buffer(bytes)
|
||||
file.flush()
|
||||
file.close()
|
||||
print(" Wrote %s (%d bytes)" % [name, bytes.size()])
|
||||
_count += 1
|
||||
|
||||
|
||||
## Encode a single PlayerInput to MessagePack bytes.
|
||||
## Mirrors Protocol.encode_player_input() from protocol.gd.
|
||||
func _encode_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
|
||||
var action: Variant
|
||||
if action_data != null:
|
||||
action = {action_name: action_data}
|
||||
elif action_name == "Interact":
|
||||
action = {"Interact": {"target_entity_id": null, "verb": null}}
|
||||
else:
|
||||
action = action_name
|
||||
|
||||
var result = _Msgpack.encode({"tick": tick, "action": action})
|
||||
if result.status != null:
|
||||
push_error("Encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode an array of PlayerInputs to MessagePack bytes.
|
||||
## Mirrors Protocol.encode_player_inputs() from protocol.gd.
|
||||
func _encode_inputs(inputs: Array) -> PackedByteArray:
|
||||
var wire_inputs: Array = []
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
var action: Variant
|
||||
if action_data != null:
|
||||
action = {action_name: action_data}
|
||||
elif action_name == "Interact":
|
||||
action = {"Interact": {"target_entity_id": null, "verb": null}}
|
||||
else:
|
||||
action = action_name
|
||||
wire_inputs.append({"tick": input["tick"], "action": action})
|
||||
|
||||
var result = _Msgpack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Batch encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
func _generate_inputs() -> void:
|
||||
# Unit variants: all 8 movement directions (tick=100)
|
||||
for dir_name in [
|
||||
"MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
"MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest",
|
||||
]:
|
||||
_write_fixture("input_%s" % dir_name.to_snake_case(),
|
||||
_encode_input(100, dir_name))
|
||||
|
||||
# Data variant: UsePerceptionMode (tick=200)
|
||||
_write_fixture("input_perception_mode",
|
||||
_encode_input(200, "UsePerceptionMode", "thermal"))
|
||||
|
||||
# Struct variant: Interact with null fields (tick=100)
|
||||
_write_fixture("input_interact",
|
||||
_encode_input(100, "Interact"))
|
||||
|
||||
# Other unit variants
|
||||
_write_fixture("input_pause",
|
||||
_encode_input(100, "Pause"))
|
||||
_write_fixture("input_unpause",
|
||||
_encode_input(100, "Unpause"))
|
||||
_write_fixture("input_toggle_stance_up",
|
||||
_encode_input(100, "ToggleStanceUp"))
|
||||
_write_fixture("input_toggle_stance_down",
|
||||
_encode_input(100, "ToggleStanceDown"))
|
||||
_write_fixture("input_walk_away",
|
||||
_encode_input(100, "WalkAway"))
|
||||
|
||||
|
||||
func _generate_boundary_inputs() -> void:
|
||||
# Tick values at MessagePack encoding format boundaries.
|
||||
# GDScript encodes 256-32767 as int_16 (0xd1); Rust encodes as uint_16 (0xcd).
|
||||
# GDScript encodes 65536-2147483647 as int_32 (0xd2); Rust as uint_32 (0xce).
|
||||
# Both are valid MessagePack. Rust's rmp_serde must accept both.
|
||||
var boundary_ticks: Array = [
|
||||
[0, "boundary_tick_0"],
|
||||
[127, "boundary_tick_127"],
|
||||
[128, "boundary_tick_128"],
|
||||
[255, "boundary_tick_255"],
|
||||
[256, "boundary_tick_256"], # int_16 asymmetry start
|
||||
[32767, "boundary_tick_32767"], # int_16 asymmetry end
|
||||
[32768, "boundary_tick_32768"],
|
||||
[65535, "boundary_tick_65535"],
|
||||
[65536, "boundary_tick_65536"], # int_32 asymmetry start
|
||||
[2147483647, "boundary_tick_2147483647"], # int_32 asymmetry end
|
||||
]
|
||||
|
||||
for pair in boundary_ticks:
|
||||
_write_fixture(pair[1],
|
||||
_encode_input(pair[0], "Pause"))
|
||||
|
||||
|
||||
func _generate_batch() -> void:
|
||||
# Vec<PlayerInput> with two actions (mirrors Rust input_batch_two fixture)
|
||||
_write_fixture("input_batch_two",
|
||||
_encode_inputs([
|
||||
{"tick": 0, "action_name": "MoveNorth"},
|
||||
{"tick": 0, "action_name": "Interact"},
|
||||
]))
|
||||
@@ -0,0 +1 @@
|
||||
uid://njychcgqlvtv
|
||||
@@ -0,0 +1,612 @@
|
||||
## Anti-tedium regression tests (#494):
|
||||
## Guard against UI clutter that makes testing tedious.
|
||||
##
|
||||
## Test 1: F12 bug report capture — pressing F12 must not crash; when #495 lands,
|
||||
## the handler should pause, show capture dialog, save 3 files, unpause.
|
||||
## Test 2: Gauntlet progress UI hidden — in non-Gauntlet mode (no room_id or
|
||||
## gauntlet_mode flag in snapshot), timer and personal-bests must not appear.
|
||||
##
|
||||
## #495 blocked by #481/#490 (server). #496 blocked by #487 (server).
|
||||
## Tests stub blocked features and verify anti-tedium guards.
|
||||
## Spec ref: Sprint 9 briefing (client.md), #495, #496.
|
||||
class_name TestAntiTedium
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var _instance: Node = null
|
||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.room_id = null
|
||||
GameState.gauntlet_mode = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
## Encode a minimal valid snapshot as MessagePack bytes (same helper as P0 tests).
|
||||
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
||||
var snapshot := {
|
||||
"tick": overrides.get("tick", 1),
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": overrides.get("entities", [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0,
|
||||
"y": 10.0,
|
||||
"z": 0,
|
||||
"kind": "Player",
|
||||
"visibility": "Forward",
|
||||
}]),
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": 0,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": overrides.get("tick_rate", "Full"),
|
||||
},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"visible_tiles": [],
|
||||
"nearby_interactions": [],
|
||||
"pending_recognitions": [],
|
||||
}
|
||||
if overrides.has("current_monologue"):
|
||||
snapshot["current_monologue"] = overrides["current_monologue"]
|
||||
if overrides.has("current_dialogue"):
|
||||
snapshot["current_dialogue"] = overrides["current_dialogue"]
|
||||
# Gauntlet fields: only include if explicitly provided (absence = non-gauntlet)
|
||||
if overrides.has("room_id"):
|
||||
snapshot["room_id"] = overrides["room_id"]
|
||||
if overrides.has("gauntlet_mode"):
|
||||
snapshot["gauntlet_mode"] = overrides["gauntlet_mode"]
|
||||
var result = Messagepack.encode(snapshot)
|
||||
return result.value
|
||||
|
||||
|
||||
func _make_gauntlet_hud() -> Control:
|
||||
var hud = Control.new()
|
||||
hud.set_script(GauntletHUDScript)
|
||||
auto_free(hud)
|
||||
add_child(hud)
|
||||
hud._personal_bests = {} # Clear any stats loaded from disk
|
||||
return hud
|
||||
|
||||
|
||||
func _make_bug_report_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
# -- Test 1: F12 Bug Report Capture -------------------------------------------
|
||||
# #495: BUG_REPORT action triggers the WRONG button capture flow.
|
||||
# Verifies: dialog exists, activates on action, no state corruption.
|
||||
# Note: Input.parse_input_event + _unhandled_input is unreliable in headless
|
||||
# test mode. Tests drive the input queue directly for deterministic coverage.
|
||||
|
||||
func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
# Verify the BugReportDialog node is present and hidden by default.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
_instance._process(0.016)
|
||||
|
||||
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
|
||||
assert_that(dialog).override_failure_message(
|
||||
"BugReportDialog node should exist in scene tree"
|
||||
).is_not_null()
|
||||
if dialog is CanvasItem:
|
||||
assert_that((dialog as CanvasItem).visible).override_failure_message(
|
||||
"BugReportDialog should be hidden by default"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_bug_report_activates_on_action() -> void:
|
||||
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
||||
# triggers the dialog. This tests the full main._process() handling path.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
_instance._process(0.016)
|
||||
|
||||
# Record baseline state
|
||||
var mono_before: Variant = GameState.current_monologue
|
||||
var dialogue_before: Variant = GameState.current_dialogue
|
||||
|
||||
# Inject BUG_REPORT action into InputMapper queue
|
||||
InputMapper.input_queue.append({
|
||||
"action": InputMapper.Action.BUG_REPORT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
# Process a frame — main.gd flushes the queue and triggers dialog
|
||||
_instance._process(0.016)
|
||||
|
||||
# Assert: dialog should be active
|
||||
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
|
||||
assert_that(dialog).is_not_null()
|
||||
if dialog and dialog.has_method("is_active"):
|
||||
assert_that(dialog.is_active()).override_failure_message(
|
||||
"BugReportDialog should be active after BUG_REPORT action"
|
||||
).is_true()
|
||||
|
||||
# Assert: no unintended state corruption
|
||||
assert_that(GameState.current_monologue).override_failure_message(
|
||||
"BUG_REPORT should not spawn a monologue"
|
||||
).is_equal(mono_before)
|
||||
assert_that(GameState.current_dialogue).override_failure_message(
|
||||
"BUG_REPORT should not spawn a dialogue"
|
||||
).is_equal(dialogue_before)
|
||||
|
||||
|
||||
# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode -----------------
|
||||
# When #496 lands, it adds a room timer and personal-bests overlay.
|
||||
# Anti-tedium guard: these must NOT be visible in normal (non-Gauntlet) play.
|
||||
#
|
||||
# Current state: no gauntlet UI exists in the scene tree.
|
||||
# This test guards against #496 accidentally showing gauntlet UI in all modes.
|
||||
|
||||
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
|
||||
# Load main scene — represents non-Gauntlet (default) play mode
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
# Process one frame to initialize all children
|
||||
_instance._process(0.016)
|
||||
|
||||
# Check that no gauntlet-specific UI nodes are visible in the scene tree.
|
||||
# When #496 adds GauntletHUD/RoomTimer/PersonalBests, they must be hidden
|
||||
# by default (only shown when gauntlet_mode is true in the snapshot).
|
||||
var gauntlet_node_names := [
|
||||
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
|
||||
"GauntletTimer", "GauntletProgress",
|
||||
]
|
||||
for node_name in gauntlet_node_names:
|
||||
var node: Node = _find_node_recursive(_instance, node_name)
|
||||
if node != null and node is CanvasItem:
|
||||
assert_that((node as CanvasItem).visible).override_failure_message(
|
||||
"Gauntlet UI node '%s' must not be visible in non-Gauntlet mode" % node_name
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
|
||||
# A snapshot that lacks room_id and gauntlet_mode fields represents
|
||||
# normal play. Apply it and verify no gauntlet state leaks into GameState.
|
||||
var bytes := _make_snapshot_bytes({"tick": 1})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
|
||||
var snapshot: Variant = SimBridge._last_snapshot
|
||||
assert_that(snapshot).is_not_null()
|
||||
|
||||
# Snapshot should NOT contain gauntlet fields
|
||||
assert_that(snapshot.has("room_id")).override_failure_message(
|
||||
"Non-gauntlet snapshot must not contain room_id"
|
||||
).is_false()
|
||||
assert_that(snapshot.has("gauntlet_mode")).override_failure_message(
|
||||
"Non-gauntlet snapshot must not contain gauntlet_mode"
|
||||
).is_false()
|
||||
|
||||
# Apply to GameState — gauntlet-related state should not exist
|
||||
GameState.apply_snapshot(snapshot)
|
||||
# #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false).
|
||||
# A non-gauntlet snapshot must leave these at their defaults.
|
||||
assert_that(GameState.room_id).override_failure_message(
|
||||
"GameState.room_id should be null in non-gauntlet mode"
|
||||
).is_null()
|
||||
assert_that(GameState.gauntlet_mode).override_failure_message(
|
||||
"GameState.gauntlet_mode should be false in non-gauntlet mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
# Simulate several ticks of normal play — gauntlet UI must never appear.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
# Process 5 frames with normal (non-gauntlet) snapshots
|
||||
for tick in range(1, 6):
|
||||
var bytes := _make_snapshot_bytes({"tick": tick})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
_instance._process(0.016)
|
||||
|
||||
# After 5 frames, no gauntlet nodes should have appeared
|
||||
var gauntlet_node_names := [
|
||||
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
|
||||
"GauntletTimer", "GauntletProgress",
|
||||
]
|
||||
for node_name in gauntlet_node_names:
|
||||
var node: Node = _find_node_recursive(_instance, node_name)
|
||||
if node != null and node is CanvasItem:
|
||||
assert_that((node as CanvasItem).visible).override_failure_message(
|
||||
"Gauntlet UI '%s' must stay hidden after %d non-gauntlet ticks" % [node_name, 5]
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
|
||||
# Timer lifecycle, personal bests, room change, visibility.
|
||||
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
|
||||
|
||||
func test_format_time_zero() -> void:
|
||||
# Static utility: 0 seconds → "00:00"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(0.0)).is_equal("00:00")
|
||||
|
||||
|
||||
func test_format_time_sub_minute() -> void:
|
||||
# 47.9 seconds (truncates, no rounding) → "00:47"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(47.9)).is_equal("00:47")
|
||||
|
||||
|
||||
func test_format_time_over_minute() -> void:
|
||||
# 98.3 seconds → 1 min 38 sec → "01:38"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(98.3)).is_equal("01:38")
|
||||
|
||||
|
||||
func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void:
|
||||
# HUD must become visible when gauntlet_mode is true in the snapshot.
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud.visible).is_false()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"GauntletHUD must be visible when gauntlet_mode is true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void:
|
||||
# HUD must hide when gauntlet_mode transitions from true to false.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).is_true()
|
||||
# Switch to non-gauntlet
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.room_id = null
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"GauntletHUD must hide when gauntlet_mode becomes false"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_starts_on_room_entry() -> void:
|
||||
# Timer starts running and tracks room_id after room entry.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should be running after room entry"
|
||||
).is_true()
|
||||
assert_that(hud.get_current_room_id()).is_equal("room_1")
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_increments_with_delta() -> void:
|
||||
# Timer accumulates real time via _process(delta).
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(0.5)
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should increment by delta (0.5s)"
|
||||
).is_equal_approx(0.5, 0.01)
|
||||
hud._process(1.0)
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should accumulate (0.5 + 1.0 = 1.5s)"
|
||||
).is_equal_approx(1.5, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_resets_on_room_change() -> void:
|
||||
# Changing room_id resets the timer to 0 and starts counting for the new room.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(5.0)
|
||||
assert_that(hud.get_timer_seconds() > 4.0).is_true()
|
||||
# Change room
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should reset to 0 on room change"
|
||||
).is_equal_approx(0.0, 0.01)
|
||||
assert_that(hud.get_current_room_id()).is_equal("room_2")
|
||||
assert_that(hud.is_timer_running()).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_hud_records_personal_best() -> void:
|
||||
# Completing a room (by entering a new room) records a personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(10.0)
|
||||
# Entering room_2 triggers _record_room_completion for room_1
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB for room_1 should be ~10.0s after first completion"
|
||||
).is_equal_approx(10.0, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_non_pb_does_not_overwrite() -> void:
|
||||
# A worse time must not overwrite an existing personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(10.0) # Worse time than existing PB
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB should remain 5.0 when new time (10.0) is worse"
|
||||
).is_equal_approx(5.0, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_pb_overwrites_when_faster() -> void:
|
||||
# A faster time must overwrite an existing personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(8.0) # Better time
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB should update to 8.0 when new time beats old PB (15.0)"
|
||||
).is_equal_approx(8.0, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void:
|
||||
# Gauntlet mode active but no room_id yet — HUD visible, timer stopped.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = null
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"HUD should be visible in gauntlet mode even without room_id"
|
||||
).is_true()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should NOT run when room_id is null"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_paused_when_not_visible() -> void:
|
||||
# Timer should not accumulate when HUD is not visible (non-gauntlet mode).
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(2.0)
|
||||
assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01)
|
||||
# Switch to non-gauntlet (hides HUD)
|
||||
GameState.gauntlet_mode = false
|
||||
hud.update_from_state()
|
||||
hud._process(5.0) # Should NOT accumulate
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer must not accumulate when HUD is hidden"
|
||||
).is_equal_approx(2.0, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_finalize_records_current_room() -> void:
|
||||
# finalize() records the current room's time and stops the timer.
|
||||
# Called on disconnect via _on_connection_state_changed.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(7.5)
|
||||
hud.finalize()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should stop after finalize"
|
||||
).is_false()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"Finalize should record current room's time as PB"
|
||||
).is_equal_approx(7.5, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_session_tracks_attempts() -> void:
|
||||
# Each room entry increments the attempt counter in _session_rooms.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(3.0)
|
||||
# Re-enter same room (via room change and back)
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud._session_rooms.has("room_1")).is_true()
|
||||
assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message(
|
||||
"Room should show 2 attempts after two entries"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
|
||||
# Snapshot with gauntlet fields applied to GameState, consumed by HUD.
|
||||
# End-to-end: bytes → decode → apply → update_from_state.
|
||||
var bytes := _make_snapshot_bytes({
|
||||
"tick": 10,
|
||||
"gauntlet_mode": true,
|
||||
"room_id": "warehouse_01",
|
||||
})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
GameState.apply_snapshot(SimBridge._last_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
assert_that(GameState.room_id).is_equal("warehouse_01")
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).is_true()
|
||||
assert_that(hud.get_current_room_id()).is_equal("warehouse_01")
|
||||
assert_that(hud.is_timer_running()).is_true()
|
||||
|
||||
|
||||
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
||||
# Pause/unpause lifecycle, wire guard, text render, state machine.
|
||||
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
|
||||
|
||||
func test_bug_report_sends_pause_on_open() -> void:
|
||||
# start_capture() must send Pause to the server.
|
||||
SimBridge.connect_to_sim()
|
||||
SimBridge._test_input_queue.clear()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
assert_that(dialog.is_active()).is_true()
|
||||
assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message(
|
||||
"Opening bug report should send Pause to server"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_sends_unpause_on_close() -> void:
|
||||
# _close() must send Unpause to the server.
|
||||
SimBridge.connect_to_sim()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog._close()
|
||||
assert_that(dialog.is_active()).is_false()
|
||||
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
|
||||
"Closing bug report should send Unpause to server"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_not_double_activatable() -> void:
|
||||
# Calling start_capture() twice must not double-activate or send duplicate Pause.
|
||||
SimBridge.connect_to_sim()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
assert_that(dialog.is_active()).is_true()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog.start_capture() # Second call — should be blocked
|
||||
assert_that(SimBridge._test_input_queue.size()).override_failure_message(
|
||||
"Double-activation should be blocked (no second Pause sent)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_bug_report_action_not_on_wire() -> void:
|
||||
# BUG_REPORT is client-only — must not produce a wire-format action name.
|
||||
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.BUG_REPORT)
|
||||
assert_that(wire_name).override_failure_message(
|
||||
"BUG_REPORT must not produce a wire action name (client-only)"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
func test_bug_report_render_text_contains_tick_and_entities() -> void:
|
||||
# _render_snapshot_text() must include tick number and entity data from GameState.
|
||||
GameState.current_tick = 42
|
||||
GameState.player_position = Vector2(5.0, 7.0)
|
||||
GameState.player_facing = "South"
|
||||
GameState.player_stance = "Crouch"
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1, "x": 5.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward",
|
||||
}]
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.contains("t42")).override_failure_message(
|
||||
"Render text should contain tick number"
|
||||
).is_true()
|
||||
assert_that(text.contains("Entities (1)")).override_failure_message(
|
||||
"Render text should show entity count"
|
||||
).is_true()
|
||||
assert_that(text.contains("Player")).override_failure_message(
|
||||
"Render text should contain entity kind"
|
||||
).is_true()
|
||||
assert_that(text.contains("South")).override_failure_message(
|
||||
"Render text should contain player facing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_render_text_with_monologue_and_dialogue() -> void:
|
||||
# _render_snapshot_text() should include active monologue and dialogue.
|
||||
GameState.current_tick = 10
|
||||
GameState.player_position = Vector2(3.0, 3.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"}
|
||||
GameState.current_monologue = {"text": "Something is wrong here."}
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"}
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.contains("Something is wrong here.")).override_failure_message(
|
||||
"Render text should include monologue text"
|
||||
).is_true()
|
||||
assert_that(text.contains("Kael")).override_failure_message(
|
||||
"Render text should include dialogue NPC name"
|
||||
).is_true()
|
||||
assert_that(text.contains("Who are you?")).override_failure_message(
|
||||
"Render text should include dialogue speech"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_render_text_empty_snapshot() -> void:
|
||||
# Edge case: F12 pressed before any snapshot data arrives.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
# Should not crash, should produce some output
|
||||
assert_that(text.length() > 0).override_failure_message(
|
||||
"Render text should not be empty even with no snapshot data"
|
||||
).is_true()
|
||||
assert_that(text.contains("Entities (0)")).override_failure_message(
|
||||
"Empty snapshot should show 0 entities"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Helper: recursive node search --------------------------------------------
|
||||
|
||||
func _find_node_recursive(root: Node, target_name: String) -> Node:
|
||||
if root.name == target_name:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var found := _find_node_recursive(child, target_name)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3fj584itowcl
|
||||
@@ -0,0 +1,361 @@
|
||||
## #507: WRONG button ring buffer tests.
|
||||
## Tests the 60-tick rolling history upgrade to bug_report_dialog.gd.
|
||||
## Covers ring buffer capacity, circular overwrite, JSONL format compliance,
|
||||
## replay compatibility, seed file, and regression against MVP behavior.
|
||||
##
|
||||
## MVP baseline (must still pass): snapshot.json, render.txt, description.txt.
|
||||
## New outputs: inputs.jsonl, snapshots.jsonl, seed.txt.
|
||||
##
|
||||
## Spec refs: D-030 (testability), D-020 (ObserverSnapshot boundary)
|
||||
## Sprint 11 client.md §Notes #507
|
||||
class_name TestBugReportRingBuffer
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
# Expected ring buffer capacity per spec.
|
||||
const EXPECTED_CAPACITY := 60
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
# Reset GameState fields mutated by tests to prevent cross-test leakage.
|
||||
GameState.current_tick = 0
|
||||
GameState.room_id = null
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_facing = "South"
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.rng_seed = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
## Create a minimal valid PlayerInput dict matching the replay.rs wire format.
|
||||
## replay.rs expects: {"tick": N, "action": "MoveNorth"} or action as dict.
|
||||
func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
|
||||
return {"tick": tick, "action": action}
|
||||
|
||||
|
||||
## Make a minimal snapshot JSON string for snapshot buffer testing.
|
||||
func _make_snapshot_json(tick: int) -> String:
|
||||
return JSON.stringify({
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": [],
|
||||
})
|
||||
|
||||
|
||||
## Check a string is valid JSON array — mirrors replay.rs line expectation.
|
||||
func _is_valid_json_array(line: String) -> bool:
|
||||
if line.is_empty():
|
||||
return false
|
||||
var result = JSON.parse_string(line)
|
||||
return result != null and result is Array
|
||||
|
||||
|
||||
# -- Ring buffer capacity ------------------------------------------------------
|
||||
|
||||
func test_buffer_capacity_is_60() -> void:
|
||||
# Spec: "Maintain a 60-entry circular buffer"
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_buffer_capacity"):
|
||||
push_warning("TestBugReportRingBuffer: _get_buffer_capacity not found — awaiting #507")
|
||||
return
|
||||
assert_that(dialog._get_buffer_capacity()).override_failure_message(
|
||||
"Ring buffer capacity must be 60 per spec"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
func test_snapshot_buffer_capacity_is_60() -> void:
|
||||
# Parallel snapshot buffer must match input buffer capacity.
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_snapshot_buffer_capacity"):
|
||||
push_warning("TestBugReportRingBuffer: _get_snapshot_buffer_capacity not found — awaiting #507")
|
||||
return
|
||||
assert_that(dialog._get_snapshot_buffer_capacity()).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
# -- Pre-allocation -----------------------------------------------------------
|
||||
|
||||
func test_input_buffer_preallocated_at_ready() -> void:
|
||||
# Spec: "pre-allocate the 60-slot arrays at startup. Do not allocate on every tick."
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_input_buffer"):
|
||||
push_warning("TestBugReportRingBuffer: _get_input_buffer not found — awaiting #507")
|
||||
return
|
||||
var buf = dialog._get_input_buffer()
|
||||
assert_that(buf is Array).override_failure_message(
|
||||
"Input buffer must be an Array"
|
||||
).is_true()
|
||||
assert_that(buf.size()).override_failure_message(
|
||||
"Input buffer must be pre-allocated at capacity (%d slots)" % EXPECTED_CAPACITY
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
# -- Push and fill behavior ---------------------------------------------------
|
||||
|
||||
func test_push_fills_input_buffer() -> void:
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs"):
|
||||
push_warning("TestBugReportRingBuffer: _push_tick_inputs not found — awaiting #507")
|
||||
return
|
||||
if not dialog.has_method("_get_filled_input_count"):
|
||||
push_warning("TestBugReportRingBuffer: _get_filled_input_count not found — awaiting #507")
|
||||
return
|
||||
for i in range(10):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
assert_that(dialog._get_filled_input_count()).override_failure_message(
|
||||
"10 pushed ticks should produce filled count of 10"
|
||||
).is_equal(10)
|
||||
|
||||
|
||||
func test_push_fills_snapshot_buffer() -> void:
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_snapshot"):
|
||||
push_warning("TestBugReportRingBuffer: _push_tick_snapshot not found — awaiting #507")
|
||||
return
|
||||
if not dialog.has_method("_get_filled_snapshot_count"):
|
||||
push_warning("TestBugReportRingBuffer: _get_filled_snapshot_count not found — awaiting #507")
|
||||
return
|
||||
for i in range(10):
|
||||
dialog._push_tick_snapshot(_make_snapshot_json(i))
|
||||
assert_that(dialog._get_filled_snapshot_count()).is_equal(10)
|
||||
|
||||
|
||||
# -- Circular overwrite -------------------------------------------------------
|
||||
|
||||
func test_circular_overwrite_evicts_oldest() -> void:
|
||||
# After 61 pushes, filled count must be 60 (oldest evicted).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
|
||||
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
|
||||
return
|
||||
for i in range(EXPECTED_CAPACITY + 1):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
assert_that(dialog._get_filled_input_count()).override_failure_message(
|
||||
"After 61 pushes, buffer must hold exactly 60 entries (oldest evicted)"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
func test_circular_overwrite_keeps_newest_inputs() -> void:
|
||||
# After 61 pushes, the flushed JSONL must contain tick 1..60 (not tick 0).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: ring buffer flush API not found — awaiting #507")
|
||||
return
|
||||
# Push 61 ticks. Tick 0 should be evicted; ticks 1-60 should be present.
|
||||
for i in range(EXPECTED_CAPACITY + 1):
|
||||
dialog._push_tick_inputs(i, [_make_input(i, "MoveNorth")])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
# Tick 0 action would be at position 0, but after 61 pushes, position 0 was overwritten.
|
||||
# We check that the output has exactly 60 lines.
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
# Filter out blank lines (PackedStringArray has no filter — convert to Array first)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).override_failure_message(
|
||||
"After 61 pushes, JSONL output must have exactly 60 non-blank lines"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
func test_exact_60_pushes_no_eviction() -> void:
|
||||
# After exactly 60 pushes, all 60 are present.
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
|
||||
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
|
||||
return
|
||||
for i in range(EXPECTED_CAPACITY):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
assert_that(dialog._get_filled_input_count()).override_failure_message(
|
||||
"After exactly 60 pushes, all 60 entries should be present"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
# -- JSONL format compliance (replay.rs contract) ----------------------------
|
||||
|
||||
func test_inputs_jsonl_each_line_is_json_array() -> void:
|
||||
# replay.rs: "Each line is a JSON array of PlayerInput for one tick."
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
for i in range(3):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
for line in non_blank:
|
||||
assert_that(_is_valid_json_array(line)).override_failure_message(
|
||||
"Each JSONL line must be a valid JSON array, got: %s" % line
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_inputs_jsonl_idle_tick_is_empty_array() -> void:
|
||||
# replay.rs: "Empty array = idle tick (no input sent)."
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
dialog._push_tick_inputs(0, []) # Idle tick: no inputs
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).override_failure_message(
|
||||
"One idle tick should produce exactly 1 JSONL line"
|
||||
).is_equal(1)
|
||||
assert_that(non_blank[0].strip_edges()).override_failure_message(
|
||||
"Idle tick line must be an empty JSON array '[]'"
|
||||
).is_equal("[]")
|
||||
|
||||
|
||||
func test_inputs_jsonl_has_tick_field() -> void:
|
||||
# replay.rs PlayerInput expects {"tick": N, "action": "..."}
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
dialog._push_tick_inputs(42, [_make_input(42, "MoveEast")])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
# Parse the first line and verify structure
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).is_greater(0)
|
||||
var arr = JSON.parse_string(non_blank[0])
|
||||
assert_that(arr is Array).is_true()
|
||||
assert_that(arr.size() > 0).is_true()
|
||||
var first_input: Dictionary = arr[0]
|
||||
assert_that(first_input.has("tick")).override_failure_message(
|
||||
"Each PlayerInput in JSONL must have 'tick' field"
|
||||
).is_true()
|
||||
assert_that(first_input.has("action")).override_failure_message(
|
||||
"Each PlayerInput in JSONL must have 'action' field"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_inputs_jsonl_multiple_actions_per_tick() -> void:
|
||||
# replay.rs supports multiple PlayerInput per tick (one array entry per action).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
dialog._push_tick_inputs(0, [
|
||||
_make_input(0, "MoveNorth"),
|
||||
_make_input(0, "Pause"),
|
||||
])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).is_equal(1)
|
||||
var arr = JSON.parse_string(non_blank[0])
|
||||
assert_that(arr is Array).is_true()
|
||||
assert_that(arr.size()).override_failure_message(
|
||||
"Tick with 2 actions must have 2 entries in the JSONL array"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_snapshots_jsonl_each_line_is_valid_json() -> void:
|
||||
# Each snapshot line should be valid JSON (parsed snapshot).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_snapshot") or not dialog.has_method("_format_snapshots_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: snapshots JSONL API not found — awaiting #507")
|
||||
return
|
||||
for i in range(3):
|
||||
dialog._push_tick_snapshot(_make_snapshot_json(i))
|
||||
var jsonl: String = dialog._format_snapshots_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).is_equal(3)
|
||||
for line in non_blank:
|
||||
var parsed = JSON.parse_string(line)
|
||||
assert_that(parsed != null).override_failure_message(
|
||||
"Each snapshot line must be valid JSON, got: %s" % line
|
||||
).is_true()
|
||||
assert_that(parsed is Dictionary).override_failure_message(
|
||||
"Each snapshot line must parse to a Dictionary"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Seed file ----------------------------------------------------------------
|
||||
|
||||
func test_seed_written_on_capture() -> void:
|
||||
# Spec: "Include the server's current RNG seed... Output as seed.txt"
|
||||
# We can't easily verify file output in headless mode, but we verify
|
||||
# the dialog has the method to produce the seed value.
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_current_seed"):
|
||||
push_warning("TestBugReportRingBuffer: _get_current_seed not found — awaiting #507")
|
||||
return
|
||||
# Should return some Variant (int or String) without crashing
|
||||
var seed_val = dialog._get_current_seed()
|
||||
assert_that(seed_val != null).override_failure_message(
|
||||
"_get_current_seed() must not return null (use 0 or 'unavailable' if seed unavailable)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Room metadata regression -------------------------------------------------
|
||||
|
||||
func test_description_txt_still_contains_room_id() -> void:
|
||||
# Regression: MVP description.txt must still include room metadata.
|
||||
GameState.current_tick = 5
|
||||
GameState.room_id = "warehouse_01"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog = _make_dialog()
|
||||
# Call the render text method to verify room shows up in render output
|
||||
# (description.txt itself writes via FileAccess, difficult in headless — test the render path)
|
||||
if dialog.has_method("_render_snapshot_text"):
|
||||
# The text render is for render.txt, but description.txt room comes from GameState.room_id
|
||||
# We indirectly verify by checking that the game state has the right fields.
|
||||
assert_that(GameState.room_id).is_equal("warehouse_01")
|
||||
GameState.room_id = null
|
||||
|
||||
|
||||
# -- MVP regression -----------------------------------------------------------
|
||||
|
||||
func test_render_snapshot_text_still_works() -> void:
|
||||
# Regression: MVP _render_snapshot_text() must still work after upgrade.
|
||||
GameState.current_tick = 10
|
||||
GameState.player_position = Vector2(5.0, 5.0)
|
||||
GameState.player_facing = "East"
|
||||
GameState.player_stance = "Careful"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_render_snapshot_text"):
|
||||
push_warning("TestBugReportRingBuffer: _render_snapshot_text missing — regression risk")
|
||||
return
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.length() > 0).is_true()
|
||||
assert_that(text.contains("t10")).is_true()
|
||||
|
||||
|
||||
func test_dialog_is_active_api_unchanged() -> void:
|
||||
# Regression: public API from MVP must be unchanged.
|
||||
var dialog = _make_dialog()
|
||||
assert_that(dialog.has_method("start_capture")).override_failure_message(
|
||||
"start_capture() must still be present (MVP regression)"
|
||||
).is_true()
|
||||
assert_that(dialog.has_method("is_active")).override_failure_message(
|
||||
"is_active() must still be present (MVP regression)"
|
||||
).is_true()
|
||||
@@ -0,0 +1 @@
|
||||
uid://vw0iqhai1kum
|
||||
@@ -0,0 +1,692 @@
|
||||
## #503: Auto-checklist progress tracking — unit + integration tests.
|
||||
##
|
||||
## Tests cover:
|
||||
## 1. YAML parser: basic types, conditions array, edge cases
|
||||
## 2. Condition evaluation: all 7 condition types
|
||||
## 3. Latching: conditions stay met once satisfied
|
||||
## 4. Room change: per-room conditions reset, cross-room conditions persist
|
||||
## 5. Overlay: visibility gating on gauntlet_mode
|
||||
## 6. Integration: snapshot -> GameState -> evaluator -> overlay
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof.
|
||||
class_name TestChecklist
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd")
|
||||
var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.nearby_interactions = []
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.room_id = null
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
|
||||
|
||||
# -- YAML Parser Tests ---------------------------------------------------------
|
||||
|
||||
func test_parse_empty_yaml() -> void:
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("")
|
||||
assert_that(result.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_parse_top_level_string() -> void:
|
||||
var yaml := "room_id: inventory_warehouse"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("inventory_warehouse")
|
||||
|
||||
|
||||
func test_parse_top_level_quoted_string() -> void:
|
||||
var yaml := 'description: "Tests D-065 (9-slot inventory)."'
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).")
|
||||
|
||||
|
||||
func test_parse_single_condition() -> void:
|
||||
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.has("conditions")).is_true()
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(1)
|
||||
assert_that(conditions[0]["id"]).is_equal("test-1")
|
||||
assert_that(conditions[0]["condition_type"]).is_equal("player_near")
|
||||
assert_that(conditions[0]["x"]).is_equal(10)
|
||||
assert_that(conditions[0]["y"]).is_equal(20)
|
||||
assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001)
|
||||
|
||||
|
||||
func test_parse_multiple_conditions() -> void:
|
||||
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(2)
|
||||
assert_that(conditions[0]["id"]).is_equal("cond-a")
|
||||
assert_that(conditions[1]["id"]).is_equal("cond-b")
|
||||
assert_that(conditions[1]["direction"]).is_equal("East")
|
||||
|
||||
|
||||
func test_parse_comments_ignored() -> void:
|
||||
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("test")
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(1)
|
||||
assert_that(conditions[0]["entity_id"]).is_equal(5)
|
||||
|
||||
|
||||
func test_parse_integer_and_float_values() -> void:
|
||||
var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
var cond: Dictionary = result["conditions"][0]
|
||||
assert_that(cond["x"]).is_equal(42)
|
||||
assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT)
|
||||
|
||||
|
||||
func test_parse_scope_field() -> void:
|
||||
var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("scope")).is_equal("cross_room")
|
||||
|
||||
|
||||
func test_parse_inline_comment_stripped() -> void:
|
||||
var yaml := "room_id: test # this is a comment"
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("test")
|
||||
|
||||
|
||||
# -- Condition Evaluation Tests ------------------------------------------------
|
||||
|
||||
func _make_evaluator(conditions: Array):
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
evaluator._room_conditions = conditions
|
||||
evaluator._loaded = true
|
||||
return evaluator
|
||||
|
||||
|
||||
func test_eval_player_near_within_radius() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "near-1", "condition_type": "player_near",
|
||||
"x": 10, "y": 21, "radius": 2.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near: player at (10,20), target (10,21), radius 2.0 — should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_player_near_outside_radius() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "near-2", "condition_type": "player_near",
|
||||
"x": 10, "y": 30, "radius": 2.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_player_near_exact_boundary() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "near-3", "condition_type": "player_near",
|
||||
"x": 10, "y": 22, "radius": 2.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near: distance exactly equals radius — should be met (<=)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_player_facing_match() -> void:
|
||||
GameState.player_facing = "East"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "face-1", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_player_facing_no_match() -> void:
|
||||
GameState.player_facing = "North"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "face-2", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_player_facing_diagonal_no_match() -> void:
|
||||
# 8-directional facing "Northeast" should NOT match "East" or "North"
|
||||
GameState.player_facing = "Northeast"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "face-diag", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_facing: Northeast should NOT match East (exact match only)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_entity_present_found() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
{"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "present-1", "condition_type": "entity_present",
|
||||
"entity_id": 10,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_entity_present_not_found() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "present-2", "condition_type": "entity_present",
|
||||
"entity_id": 99,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_entity_absent_when_not_visible() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "absent-1", "condition_type": "entity_absent",
|
||||
"entity_id": 99,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"entity_absent: entity 99 not in visible_entities — should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_entity_absent_when_visible() -> void:
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"},
|
||||
]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "absent-2", "condition_type": "entity_absent",
|
||||
"entity_id": 10,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"entity_absent: entity 10 IS visible — should NOT be met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_monologue_match() -> void:
|
||||
GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-1", "condition_type": "expected_monologue",
|
||||
"contains": "wrong here",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_expected_monologue_no_match() -> void:
|
||||
GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-2", "condition_type": "expected_monologue",
|
||||
"contains": "wrong here",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_monologue_null() -> void:
|
||||
GameState.current_monologue = null
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-3", "condition_type": "expected_monologue",
|
||||
"contains": "test",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_monologue: null monologue should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_dialogue_match() -> void:
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "dlg-1", "condition_type": "expected_dialogue",
|
||||
"contains": "Who are you",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_expected_dialogue_no_match() -> void:
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []}
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "dlg-2", "condition_type": "expected_dialogue",
|
||||
"contains": "Goodbye",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_expected_dialogue_null() -> void:
|
||||
GameState.current_dialogue = null
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "dlg-3", "condition_type": "expected_dialogue",
|
||||
"contains": "test",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_dialogue: null dialogue should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_match() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
|
||||
{"kind": "Observe", "label": "Examine", "priority": 2, "available": true},
|
||||
],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-1", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_by_kind() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Take", "label": "Pickup", "priority": 1, "available": true},
|
||||
],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-kind", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Take",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_interaction_verb: should match by kind='Take' as well as label"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_wrong_entity() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-wrong", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 99, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_interaction_verb: wrong entity_id should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_unavailable() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 13,
|
||||
"entity_type": "Object",
|
||||
"distance": 1,
|
||||
"verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}],
|
||||
}]
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-unavail", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"expected_interaction_verb: unavailable verb should not match"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_eval_interaction_verb_no_interactions() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "verb-none", "condition_type": "expected_interaction_verb",
|
||||
"entity_id": 13, "verb": "Pickup",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
# -- Latching Tests ------------------------------------------------------------
|
||||
|
||||
func test_latching_condition_stays_met() -> void:
|
||||
# Condition met on first evaluate, stays met even when state changes.
|
||||
GameState.player_facing = "East"
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "latch-1", "condition_type": "player_facing",
|
||||
"direction": "East",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
# Change state so condition would be false if re-evaluated fresh
|
||||
GameState.player_facing = "North"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"Latched condition should stay met even after state changes"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_latching_monologue_transient() -> void:
|
||||
# Monologue appears for one tick, then disappears. Condition should latch.
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "mono-latch", "condition_type": "expected_monologue",
|
||||
"contains": "recalibrated",
|
||||
}])
|
||||
|
||||
# Tick 1: no monologue
|
||||
GameState.current_monologue = null
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
# Tick 2: monologue fires
|
||||
GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0}
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
# Tick 3: monologue consumed (null)
|
||||
GameState.current_monologue = null
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"Monologue condition should stay latched after monologue disappears"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_multiple_conditions_partial_latching() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
|
||||
{"id": "c2", "condition_type": "entity_present", "entity_id": 5},
|
||||
{"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0},
|
||||
])
|
||||
|
||||
# Tick 1: only facing matches
|
||||
GameState.player_facing = "East"
|
||||
GameState.visible_entities = []
|
||||
GameState.player_position = Vector2(0, 0)
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
assert_that(evaluator.get_total_count()).is_equal(3)
|
||||
|
||||
# Tick 2: entity also visible
|
||||
GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}]
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(2)
|
||||
|
||||
# Tick 3: player moves to target
|
||||
GameState.player_position = Vector2(50.0, 50.0)
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(3)
|
||||
assert_that(evaluator.is_complete()).is_true()
|
||||
|
||||
|
||||
# -- Room Change Tests ---------------------------------------------------------
|
||||
|
||||
func test_room_change_resets_per_room_conditions() -> void:
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
# Manually set conditions to avoid file loading
|
||||
evaluator._room_conditions = [
|
||||
{"id": "r1-c1", "condition_type": "player_facing", "direction": "East"},
|
||||
]
|
||||
evaluator._loaded = true
|
||||
GameState.player_facing = "East"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
|
||||
# Simulate room change by loading a new "room"
|
||||
evaluator._current_room_id = "old_room"
|
||||
evaluator._room_conditions = [
|
||||
{"id": "r2-c1", "condition_type": "player_facing", "direction": "North"},
|
||||
]
|
||||
# Clear latches for the new room (simulating load_room behavior)
|
||||
evaluator._latched.clear()
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"After room change, old latches should be cleared; new condition not met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_reset_clears_all_state() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "condition_type": "player_facing", "direction": "East"},
|
||||
])
|
||||
GameState.player_facing = "East"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
evaluator.reset()
|
||||
assert_that(evaluator.is_loaded()).is_false()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
assert_that(evaluator.get_total_count()).is_equal(0)
|
||||
|
||||
|
||||
# -- get_results Tests ---------------------------------------------------------
|
||||
|
||||
func test_get_results_structure() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"},
|
||||
])
|
||||
GameState.player_facing = "North"
|
||||
evaluator.evaluate()
|
||||
var results: Array = evaluator.get_results()
|
||||
assert_that(results.size()).is_equal(1)
|
||||
assert_that(results[0]["id"]).is_equal("c1")
|
||||
assert_that(results[0]["description"]).is_equal("Test condition")
|
||||
assert_that(results[0]["condition_type"]).is_equal("player_facing")
|
||||
assert_that(results[0]["met"]).is_true()
|
||||
|
||||
|
||||
func test_get_results_unmet() -> void:
|
||||
var evaluator = _make_evaluator([
|
||||
{"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"},
|
||||
])
|
||||
GameState.player_facing = "North"
|
||||
evaluator.evaluate()
|
||||
var results: Array = evaluator.get_results()
|
||||
assert_that(results[0]["met"]).is_false()
|
||||
|
||||
|
||||
# -- Overlay Visibility Tests --------------------------------------------------
|
||||
|
||||
func _make_checklist_overlay() -> Control:
|
||||
var overlay = Control.new()
|
||||
overlay.set_script(ChecklistOverlayScript)
|
||||
auto_free(overlay)
|
||||
add_child(overlay)
|
||||
return overlay
|
||||
|
||||
|
||||
func test_overlay_hidden_in_non_gauntlet_mode() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
GameState.gauntlet_mode = false
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).override_failure_message(
|
||||
"Checklist overlay must be hidden in non-gauntlet mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_overlay_visible_in_gauntlet_mode() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "test_room"
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).override_failure_message(
|
||||
"Checklist overlay must be visible in gauntlet mode"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overlay_hides_on_gauntlet_deactivation() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "test_room"
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).is_true()
|
||||
GameState.gauntlet_mode = false
|
||||
overlay.update_from_state()
|
||||
assert_that(overlay.visible).override_failure_message(
|
||||
"Overlay must hide when gauntlet mode deactivates"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_overlay_evaluator_accessible() -> void:
|
||||
var overlay := _make_checklist_overlay()
|
||||
var evaluator = overlay.get_evaluator()
|
||||
assert_that(evaluator).override_failure_message(
|
||||
"Overlay should expose evaluator via get_evaluator()"
|
||||
).is_not_null()
|
||||
|
||||
|
||||
func test_overlay_in_main_scene() -> void:
|
||||
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||
var instance: Node = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
instance._process(0.016)
|
||||
|
||||
var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay")
|
||||
assert_that(overlay).override_failure_message(
|
||||
"ChecklistOverlay node should exist in main scene tree"
|
||||
).is_not_null()
|
||||
if overlay is CanvasItem:
|
||||
assert_that((overlay as CanvasItem).visible).override_failure_message(
|
||||
"ChecklistOverlay should be hidden by default (non-gauntlet mode)"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Integration: Snapshot -> Evaluation ----------------------------------------
|
||||
|
||||
func test_integration_snapshot_to_evaluator() -> void:
|
||||
# Integration test: GameState snapshot data -> evaluator -> correct results.
|
||||
# Tests the evaluator directly (overlay wiring tested separately).
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
evaluator._room_conditions = [
|
||||
{"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1},
|
||||
{"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"},
|
||||
]
|
||||
evaluator._loaded = true
|
||||
|
||||
# Simulate gauntlet snapshot with player entity
|
||||
GameState.visible_entities = [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"},
|
||||
]
|
||||
GameState.player_facing = "North"
|
||||
|
||||
evaluator.evaluate()
|
||||
|
||||
# Entity present should be met, facing should not
|
||||
assert_that(evaluator.get_met_count()).is_equal(1)
|
||||
assert_that(evaluator.get_total_count()).is_equal(2)
|
||||
|
||||
# Change facing — second condition should also latch
|
||||
GameState.player_facing = "East"
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(2)
|
||||
assert_that(evaluator.is_complete()).is_true()
|
||||
|
||||
# Verify results array contains both conditions as met
|
||||
var results: Array = evaluator.get_results()
|
||||
for r in results:
|
||||
assert_that(r["met"]).override_failure_message(
|
||||
"Condition '%s' should be met after snapshot sequence" % r["id"]
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Edge Cases ----------------------------------------------------------------
|
||||
|
||||
func test_empty_entity_list_entity_present() -> void:
|
||||
GameState.visible_entities = []
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-empty", "condition_type": "entity_present",
|
||||
"entity_id": 0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_empty_entity_list_entity_absent() -> void:
|
||||
GameState.visible_entities = []
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-absent-empty", "condition_type": "entity_absent",
|
||||
"entity_id": 99,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"entity_absent with empty visible_entities should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_zero_radius_player_near() -> void:
|
||||
GameState.player_position = Vector2(10.0, 20.0)
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-zero-radius", "condition_type": "player_near",
|
||||
"x": 10, "y": 20, "radius": 0.0,
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"player_near with radius 0 at exact position should be met"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_unknown_condition_type() -> void:
|
||||
var evaluator = _make_evaluator([{
|
||||
"id": "edge-unknown", "condition_type": "nonexistent_type",
|
||||
}])
|
||||
evaluator.evaluate()
|
||||
assert_that(evaluator.get_met_count()).override_failure_message(
|
||||
"Unknown condition type should not be met"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_no_conditions_loaded() -> void:
|
||||
var evaluator = ChecklistEvaluatorScript.new()
|
||||
assert_that(evaluator.is_loaded()).is_false()
|
||||
assert_that(evaluator.get_total_count()).is_equal(0)
|
||||
assert_that(evaluator.get_met_count()).is_equal(0)
|
||||
assert_that(evaluator.is_complete()).is_false()
|
||||
|
||||
|
||||
# -- Helper: recursive node search --------------------------------------------
|
||||
|
||||
func _find_node_recursive(root: Node, target_name: String) -> Node:
|
||||
if root.name == target_name:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var found := _find_node_recursive(child, target_name)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw0yyp8mrchi7
|
||||
@@ -0,0 +1,320 @@
|
||||
## P2 client tests: camera (5), entity alpha/color (4), UI (7).
|
||||
## Validates camera tracking, entity visual state, and UI component lifecycle.
|
||||
## Spec ref: sprint-9/client.md #492.
|
||||
class_name TestClientP2
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.nearby_interactions = []
|
||||
GameState.player_inventory = []
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_scene() -> Node:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
return _instance
|
||||
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func _make_fog_entities() -> Node2D:
|
||||
var node = Node2D.new()
|
||||
node.set_script(FogEntitiesScript)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# -- Camera (5) ----------------------------------------------------------------
|
||||
|
||||
func test_camera_zoom_default_2x() -> void:
|
||||
# P2-C01: Camera zoom should be 2x as set in main.tscn.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
assert_that(camera.zoom).is_equal(Vector2(2, 2))
|
||||
|
||||
|
||||
func test_camera_smoothing_convergence() -> void:
|
||||
# P2-C02: After first _process, smoothing re-enables for gameplay feel.
|
||||
# After several frames, camera position should still match player position
|
||||
# (smoothing converges because target == position when stationary).
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
# First frame re-enables smoothing
|
||||
inst._process(0.016)
|
||||
assert_that(camera.position_smoothing_enabled).is_true()
|
||||
# Several more frames — stationary player, camera converges
|
||||
for i in 5:
|
||||
inst._process(0.016)
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
var dist := camera.global_position.distance_to(expected)
|
||||
assert_that(dist < 0.1).override_failure_message(
|
||||
"Camera should converge to player position (dist: %.4f)" % dist
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_camera_viewport_tracks_player_position() -> void:
|
||||
# P2-C03: Camera position always equals player_position * TILE_SIZE.
|
||||
# No offset, no viewport clamping beyond player tracking.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
assert_that(camera.global_position).override_failure_message(
|
||||
"Camera must track player position exactly"
|
||||
).is_equal(expected)
|
||||
|
||||
|
||||
func test_camera_follows_player_after_movement() -> void:
|
||||
# P2-C04: After player moves, camera position updates to new player position.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
var initial_pos := camera.global_position
|
||||
# Move player south via SimBridge test mode
|
||||
SimBridge._test_input_queue.append("MoveSouth")
|
||||
inst._process(0.016)
|
||||
# Camera should have moved with player
|
||||
assert_that(camera.global_position.y > initial_pos.y).override_failure_message(
|
||||
"Camera Y should increase after moving south"
|
||||
).is_true()
|
||||
assert_that(camera.global_position).is_equal(
|
||||
GameState.player_position * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
func test_camera_no_panning_locked_to_player() -> void:
|
||||
# P2-C05 (D-014): Camera is locked to player — no panning.
|
||||
# Camera position equals player_position * TILE_SIZE every frame.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
# Process multiple frames
|
||||
for i in 10:
|
||||
inst._process(0.016)
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
assert_that(camera.global_position).override_failure_message(
|
||||
"Frame %d: camera must be locked to player" % i
|
||||
).is_equal(expected)
|
||||
|
||||
|
||||
# -- Entity alpha/color (4) ---------------------------------------------------
|
||||
|
||||
func test_entity_null_visibility_defaults_full_alpha() -> void:
|
||||
# P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[50]
|
||||
assert_that(node.modulate.a).override_failure_message(
|
||||
"Null visibility should default to full alpha"
|
||||
).is_equal_approx(1.0, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_peripheral_to_forward_alpha_transition() -> void:
|
||||
# P2-E02: Updating entity from Peripheral to Forward changes alpha.
|
||||
var renderer := _make_entity_renderer()
|
||||
# First: Peripheral
|
||||
var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}]
|
||||
renderer.update_entities(entity_p)
|
||||
var node = renderer.entity_nodes[60]
|
||||
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
|
||||
# Update to Forward
|
||||
var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity_f)
|
||||
assert_that(node.modulate.a).override_failure_message(
|
||||
"Forward visibility should set alpha to 1.0"
|
||||
).is_equal_approx(1.0, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_terrain_uses_object_color() -> void:
|
||||
# P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
|
||||
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[70] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
||||
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_player_color_regardless_of_sector() -> void:
|
||||
# P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector.
|
||||
GameState.player_entity_id = 80
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[80] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
"Player color must be constant regardless of visibility sector"
|
||||
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
# Alpha should still be dimmed for Peripheral
|
||||
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- UI (7) --------------------------------------------------------------------
|
||||
|
||||
func test_monologue_display_visible_hidden() -> void:
|
||||
# P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue.
|
||||
# Note: mono.is_visible is a custom bool property on MonologueDisplay
|
||||
# (monologue_display.gd:11), not the built-in CanvasItem.is_visible() method.
|
||||
# The monologue uses tween alpha for visual hide/show, so the built-in
|
||||
# .visible stays true — we test the script's own state tracking.
|
||||
var inst := _make_scene()
|
||||
var mono = inst.get_node("UILayer/MonologueDisplay")
|
||||
assert_that(mono.is_visible).override_failure_message(
|
||||
"Monologue should start hidden"
|
||||
).is_false()
|
||||
mono.show_monologue("Test thought.", 3.0)
|
||||
assert_that(mono.is_visible).override_failure_message(
|
||||
"Monologue should be visible after show_monologue"
|
||||
).is_true()
|
||||
assert_that(mono.text_label.text).is_equal("Test thought.")
|
||||
|
||||
|
||||
func test_interaction_list_shows_nearest_verb() -> void:
|
||||
# P2-U02: When nearby_interactions are present, interaction list shows verbs.
|
||||
var inst := _make_scene()
|
||||
var ilist = inst.get_node("InsertOverlay/InteractionList")
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "Observe", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
}]
|
||||
ilist.update_from_state()
|
||||
assert_that(ilist.get_visible_verb_count()).override_failure_message(
|
||||
"Interaction list should show 2 verbs"
|
||||
).is_equal(2)
|
||||
assert_that(ilist.is_showing()).is_true()
|
||||
# Cleanup
|
||||
GameState.nearby_interactions = []
|
||||
|
||||
|
||||
func test_inventory_grid_shows_carried_items() -> void:
|
||||
# P2-U03: Inventory grid becomes visible when player has items.
|
||||
var inst := _make_scene()
|
||||
var inv = inst.get_node("UILayer/InventoryGrid")
|
||||
# Initially empty — hidden
|
||||
GameState.player_inventory = []
|
||||
inv.update_from_state()
|
||||
assert_that(inv.visible).override_failure_message(
|
||||
"Inventory should be hidden when empty"
|
||||
).is_false()
|
||||
# Add items
|
||||
GameState.player_inventory = [
|
||||
{"item_id": 1, "name": "Keycard", "slot": 0},
|
||||
{"item_id": 2, "name": "Datapad", "slot": 1},
|
||||
]
|
||||
inv.update_from_state()
|
||||
assert_that(inv.visible).override_failure_message(
|
||||
"Inventory should be visible with items"
|
||||
).is_true()
|
||||
assert_that(inv.get_slot_count()).is_equal(2)
|
||||
# Cleanup
|
||||
GameState.player_inventory = []
|
||||
|
||||
|
||||
func test_dialogue_overlay_active_on_show() -> void:
|
||||
# P2-U04: Dialogue box reports active after show_dialogue.
|
||||
var inst := _make_scene()
|
||||
var dlg = inst.get_node("InsertOverlay/DialogueBox")
|
||||
assert_that(dlg.is_dialogue_active()).override_failure_message(
|
||||
"Dialogue should start inactive"
|
||||
).is_false()
|
||||
dlg.show_dialogue("Kael", "How's it going?", [
|
||||
{"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false},
|
||||
])
|
||||
assert_that(dlg.is_dialogue_active()).override_failure_message(
|
||||
"Dialogue should be active after show_dialogue"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_tick_rate_paused() -> void:
|
||||
# P2-U05: TickRate::Paused is stored in game_time and queryable.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": 100,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Paused",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.tick_rate).override_failure_message(
|
||||
"Paused tick rate must be stored in game_time"
|
||||
).is_equal("Paused")
|
||||
|
||||
|
||||
func test_fog_entities_blob_count_matches_recognitions() -> void:
|
||||
# P2-U06: FogEntities tracks one blob per pending recognition.
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved := GameState.pending_recognitions
|
||||
GameState.pending_recognitions = [
|
||||
{"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6},
|
||||
{"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
]
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities.size()).override_failure_message(
|
||||
"FogEntities should track 2 blob entities"
|
||||
).is_equal(2)
|
||||
assert_that(fog_entities._entities.has(101)).is_true()
|
||||
assert_that(fog_entities._entities.has(102)).is_true()
|
||||
GameState.pending_recognitions = saved
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_fog_overlay_z_index_covers_world() -> void:
|
||||
# P2-U07: FogOverlay renders above world content (z:900), ensuring
|
||||
# occluded tiles are visually covered.
|
||||
var inst := _make_scene()
|
||||
var fog_overlay = inst.get_node("World/FogOverlay")
|
||||
assert_that(fog_overlay.z_index).override_failure_message(
|
||||
"FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG
|
||||
).is_equal(Constants.Z_FOG)
|
||||
# FogEntities at z:950 — above fog, below insert overlay
|
||||
var fog_entities = inst.get_node("World/FogEntities")
|
||||
assert_that(fog_entities.z_index).override_failure_message(
|
||||
"FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES
|
||||
).is_equal(Constants.Z_FOG_ENTITIES)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci76ggq7n33w7
|
||||
@@ -0,0 +1,341 @@
|
||||
## P3 client tests: z-layer ordering (4), entity lerp (3), Tyre additions (5).
|
||||
## Validates scene tree draw order, framerate-independent entity interpolation,
|
||||
## and recognition transition timing.
|
||||
## Spec ref: sprint-9/client.md #493.
|
||||
class_name TestClientP3
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.nearby_interactions = []
|
||||
GameState.player_inventory = []
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_facing = "North"
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_scene() -> Node:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
return _instance
|
||||
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func _make_fog_entities() -> Node2D:
|
||||
var node = Node2D.new()
|
||||
node.set_script(FogEntitiesScript)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# -- Z-layer ordering (4) -----------------------------------------------------
|
||||
|
||||
func test_z_floor_below_ysort() -> void:
|
||||
# P3-Z01: FloorTiles (z:0) renders below YSortGroup (z:100).
|
||||
var inst := _make_scene()
|
||||
var floor_tiles = inst.get_node("World/FogGroup/FloorTiles")
|
||||
var ysort = inst.get_node("World/FogGroup/YSortGroup")
|
||||
assert_that(floor_tiles.z_index).override_failure_message(
|
||||
"FloorTiles z_index must be Z_FLOOR (%d)" % Constants.Z_FLOOR
|
||||
).is_equal(Constants.Z_FLOOR)
|
||||
assert_that(ysort.z_index).override_failure_message(
|
||||
"YSortGroup z_index must be Z_YSORT (%d)" % Constants.Z_YSORT
|
||||
).is_equal(Constants.Z_YSORT)
|
||||
assert_that(floor_tiles.z_index < ysort.z_index).is_true()
|
||||
|
||||
|
||||
func test_z_entities_inside_ysort_at_zero() -> void:
|
||||
# P3-Z02: Entities node lives inside YSortGroup with z_index = 0.
|
||||
# Y-sort contract: children of YSortGroup must use z_index = 0.
|
||||
var inst := _make_scene()
|
||||
var entities = inst.get_node("World/FogGroup/YSortGroup/Entities")
|
||||
assert_that(entities.z_index).override_failure_message(
|
||||
"Entities z_index must be 0 inside YSortGroup (y-sort contract)"
|
||||
).is_equal(0)
|
||||
assert_that(entities.get_parent().y_sort_enabled).override_failure_message(
|
||||
"Entities parent must have y_sort_enabled"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_z_fog_above_world_content() -> void:
|
||||
# P3-Z03: FogOverlay (z:900) renders above all world content including
|
||||
# YSortGroup (z:100) and Overhead (z:300).
|
||||
# Spec #34: FogOverlay z_index == Z_FOG (900).
|
||||
# Spec #35: FogEntities z_index == Z_FOG_ENTITIES (950).
|
||||
var inst := _make_scene()
|
||||
var fog = inst.get_node("World/FogOverlay")
|
||||
var fog_entities = inst.get_node("World/FogEntities")
|
||||
var fog_group = inst.get_node("World/FogGroup")
|
||||
var overhead = inst.get_node("World/FogGroup/Overhead")
|
||||
# FogOverlay must be a sibling of FogGroup (both children of World),
|
||||
# not a child of FogGroup — fog renders OVER the composited group.
|
||||
assert_that(fog.get_parent()).override_failure_message(
|
||||
"FogOverlay must be sibling of FogGroup (both under World)"
|
||||
).is_equal(fog_group.get_parent())
|
||||
assert_that(fog.z_index).override_failure_message(
|
||||
"FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG
|
||||
).is_equal(Constants.Z_FOG)
|
||||
assert_that(fog_entities.z_index).override_failure_message(
|
||||
"FogEntities z_index must be Z_FOG_ENTITIES (%d) per D-049/D-059" % Constants.Z_FOG_ENTITIES
|
||||
).is_equal(Constants.Z_FOG_ENTITIES)
|
||||
assert_that(fog.z_index > overhead.z_index).override_failure_message(
|
||||
"FogOverlay (z:%d) must render above Overhead (z:%d)" % [fog.z_index, overhead.z_index]
|
||||
).is_true()
|
||||
assert_that(fog_entities.z_index > fog.z_index).override_failure_message(
|
||||
"FogEntities (z:%d) must render above FogOverlay (z:%d)" % [fog_entities.z_index, fog.z_index]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_z_ui_layer_above_world() -> void:
|
||||
# P3-Z04: UILayer (CanvasLayer 20) renders above InsertOverlay (CanvasLayer 10)
|
||||
# and both render above world content.
|
||||
var inst := _make_scene()
|
||||
var ui_layer = inst.get_node("UILayer") as CanvasLayer
|
||||
var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer
|
||||
var modal_layer = inst.get_node("ModalLayer") as CanvasLayer
|
||||
assert_that(insert_layer.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
assert_that(ui_layer.layer).override_failure_message(
|
||||
"UILayer must be CanvasLayer %d" % Constants.CANVAS_UI
|
||||
).is_equal(Constants.CANVAS_UI)
|
||||
assert_that(modal_layer.layer).override_failure_message(
|
||||
"ModalLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
|
||||
).is_equal(Constants.CANVAS_MODAL)
|
||||
assert_that(ui_layer.layer > insert_layer.layer).override_failure_message(
|
||||
"UILayer must render above InsertOverlay"
|
||||
).is_true()
|
||||
assert_that(modal_layer.layer > ui_layer.layer).override_failure_message(
|
||||
"ModalLayer must render above UILayer"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Entity lerp (3) ----------------------------------------------------------
|
||||
|
||||
func test_entity_snap_on_first_appear() -> void:
|
||||
# P3-L01: Entity spawns at its position immediately — no lerp on first appear.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 10, "x": 8.0, "y": 6.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[10]
|
||||
var expected := Vector2(
|
||||
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
assert_that(node.position).override_failure_message(
|
||||
"Entity should snap to position on first appear (no lerp)"
|
||||
).is_equal(expected)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_lerp_moves_toward_target() -> void:
|
||||
# P3-L02: After updating target position, entity moves toward it over time.
|
||||
var renderer := _make_entity_renderer()
|
||||
# Spawn at (5, 5)
|
||||
var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node: ColorRect = renderer.entity_nodes[11]
|
||||
var start_pos: Vector2 = node.position
|
||||
# Move target to (6, 5)
|
||||
var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity_moved)
|
||||
# Process several frames — entity should move toward target
|
||||
renderer._process(0.016)
|
||||
renderer._process(0.016)
|
||||
var after_pos: Vector2 = node.position
|
||||
var target := Vector2(
|
||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
# Position should have moved toward target (x increased)
|
||||
assert_that(after_pos.x > start_pos.x).override_failure_message(
|
||||
"Entity x should move toward target after _process"
|
||||
).is_true()
|
||||
# But should not have snapped — still in transit
|
||||
assert_that(after_pos.x < target.x).override_failure_message(
|
||||
"Entity should still be in transit after 2 frames"
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_lerp_converges_within_300ms() -> void:
|
||||
# P3-L03: At LERP_SPEED=12.0, entity converges within ~0.3s.
|
||||
# At 12.0: weight = 1.0 - exp(-12.0 * 0.3) ≈ 0.973 — 97% there.
|
||||
var renderer := _make_entity_renderer()
|
||||
# Spawn at (5, 5)
|
||||
var entity := [{"entity_id": 12, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
# Move target to (7, 5) — 2 tiles
|
||||
var entity_moved := [{"entity_id": 12, "x": 7.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity_moved)
|
||||
var target := Vector2(
|
||||
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
||||
for i in 20:
|
||||
renderer._process(0.016)
|
||||
var final_node: ColorRect = renderer.entity_nodes[12]
|
||||
var final_pos: Vector2 = final_node.position
|
||||
# Should be within 5% of target (97% convergence at 0.3s)
|
||||
var dist: float = final_pos.distance_to(target)
|
||||
var total_dist: float = 2.0 * Constants.TILE_SIZE
|
||||
assert_that(dist / total_dist < 0.05).override_failure_message(
|
||||
"Entity should be within 5%% of target after 0.3s (dist: %.1f / %.1f)" % [dist, total_dist]
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Tyre additions (5) -------------------------------------------------------
|
||||
|
||||
func test_recognition_transition_progress() -> void:
|
||||
# P3-T01: Recognition progress calculated correctly from remaining/total ticks.
|
||||
# Grey blob → colored entity over total_delay_ticks.
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved := GameState.pending_recognitions
|
||||
# 3 remaining out of 10 total → progress 0.7
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 3, "total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
var blob: Dictionary = fog_entities._entities[200]
|
||||
assert_that(blob.progress).override_failure_message(
|
||||
"Progress should be 0.7 (1.0 - 3/10)"
|
||||
).is_equal_approx(0.7, 0.01)
|
||||
# 0 remaining → fully recognized (progress 1.0)
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 0, "total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities[200].progress).override_failure_message(
|
||||
"Zero remaining should be progress 1.0"
|
||||
).is_equal_approx(1.0, 0.01)
|
||||
GameState.pending_recognitions = saved
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
|
||||
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
|
||||
# Indicator rotation = facing_angle + PI/2 (0=North basis).
|
||||
GameState.player_entity_id = 1
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
|
||||
# {facing_angle → expected indicator rotation}
|
||||
var angles := {
|
||||
-PI / 2.0: 0.0, # North
|
||||
0.0: PI / 2.0, # East
|
||||
PI / 2.0: PI, # South
|
||||
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
|
||||
}
|
||||
for angle in angles:
|
||||
InputMapper.facing_angle = angle
|
||||
renderer.update_entities(entity)
|
||||
assert_that(indicator.rotation).override_failure_message(
|
||||
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
|
||||
).is_equal_approx(angles[angle], 0.001)
|
||||
InputMapper.facing_angle = -PI / 2.0 # Reset to default
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_lerp_weight_increases_with_delta() -> void:
|
||||
# P3-T03: Sprint snappiness — larger delta → larger lerp weight → faster arrival.
|
||||
# Exponential smoothing: weight = 1.0 - exp(-LERP_SPEED * delta).
|
||||
# Higher delta (or higher lerp multiplier) means more progress per frame.
|
||||
var renderer := _make_entity_renderer()
|
||||
# Spawn entity, then move target
|
||||
var entity := [{"entity_id": 20, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var entity_moved := [{"entity_id": 20, "x": 8.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity_moved)
|
||||
# Small delta step
|
||||
var small_node: ColorRect = renderer.entity_nodes[20]
|
||||
var small_start: float = small_node.position.x
|
||||
renderer._process(0.008)
|
||||
var small_progress: float = small_node.position.x - small_start
|
||||
# Reset position for large delta test
|
||||
small_node.position = Vector2(
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
# Large delta step
|
||||
var large_start: float = small_node.position.x
|
||||
renderer._process(0.032)
|
||||
var large_progress: float = small_node.position.x - large_start
|
||||
assert_that(large_progress > small_progress).override_failure_message(
|
||||
"Larger delta should produce more lerp progress (%.2f vs %.2f)" % [large_progress, small_progress]
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_recognition_blob_removed_when_absent() -> void:
|
||||
# P3-T04: When a pending recognition disappears from state, FogEntities
|
||||
# removes the blob.
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved := GameState.pending_recognitions
|
||||
# Add entity
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 300, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 5, "total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities.size()).is_equal(1)
|
||||
# Remove from state
|
||||
GameState.pending_recognitions = []
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities.size()).override_failure_message(
|
||||
"Blob should be removed when absent from pending_recognitions"
|
||||
).is_equal(0)
|
||||
GameState.pending_recognitions = saved
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_entity_renderer_lerp_speed_constant() -> void:
|
||||
# P3-T05: LERP_SPEED is tuned at 12.0 — exponential smoothing constant
|
||||
# for entity visual interpolation. Pin value to prevent accidental changes.
|
||||
assert_that(EntityRenderer.LERP_SPEED).override_failure_message(
|
||||
"LERP_SPEED must be 12.0 (entity movement feel constant)"
|
||||
).is_equal(12.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bas5l8rnvsjly
|
||||
@@ -0,0 +1,366 @@
|
||||
## #521: Confrontation D-033 color shift — QA test suite
|
||||
## Spec refs: D-033 (entity color = relationship), D-063 (confrontation same box)
|
||||
## Sprint Completion Proof (joint.md):
|
||||
## - Entity tint fades 0.5-1s to new relationship color on confrontation delivery
|
||||
## - Cursor hover tint also updates to match relationship
|
||||
## - Palette matches D-033 exactly
|
||||
##
|
||||
## Tests are structured in layers:
|
||||
## 1. D-033 color palette constants — always pass (no implementation dependency)
|
||||
## 2. Relationship-to-color mapping — tests the lookup function
|
||||
## 3. Entity renderer relationship coloring — tests that entities USE relationship
|
||||
## 4. Tween on relationship change — tests fade behavior (0.5-1s)
|
||||
## 5. Protocol/GameState passthrough — tests data pipeline integrity
|
||||
class_name TestColorShift
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript: GDScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var ConstantsScript: GDScript = load("res://scripts/constants.gd")
|
||||
|
||||
# -- Test data -----------------------------------------------------------------
|
||||
|
||||
# Entity with relationship field (v4 protocol format)
|
||||
func _make_entity(entity_id: int, kind: String, relationship: String, x: float = 5.0, y: float = 5.0) -> Dictionary:
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"x": x, "y": y, "z": 0,
|
||||
"kind": {"variant": kind, "data": null},
|
||||
"visibility": "Forward",
|
||||
"relationship": relationship,
|
||||
"observation": "Visible",
|
||||
}
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer: Node2D = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_entity_id = 1
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: D-033 Color Palette Constants
|
||||
# These tests verify the palette is defined correctly. No implementation needed.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d033_unknown_teal() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_UNKNOWN).is_equal(Color("#4a9ebb"))
|
||||
|
||||
func test_d033_friendly_green() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_FRIENDLY).is_equal(Color("#6bc9a6"))
|
||||
|
||||
func test_d033_poi_amber() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
|
||||
|
||||
func test_d033_hostile_red() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
|
||||
|
||||
func test_d033_object_grey() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_OBJECT).is_equal(Color("#8b8ba0"))
|
||||
|
||||
func test_d033_player_cool_white() -> void:
|
||||
assert_that(Constants.ENTITY_COLOR_PLAYER).is_equal(Color("#e0e8ff"))
|
||||
|
||||
func test_d033_palette_all_distinct() -> void:
|
||||
# All 6 D-033 colors must be distinct from each other
|
||||
var colors: Array[Color] = [
|
||||
Constants.ENTITY_COLOR_UNKNOWN,
|
||||
Constants.ENTITY_COLOR_FRIENDLY,
|
||||
Constants.ENTITY_COLOR_POI,
|
||||
Constants.ENTITY_COLOR_HOSTILE,
|
||||
Constants.ENTITY_COLOR_OBJECT,
|
||||
Constants.ENTITY_COLOR_PLAYER,
|
||||
]
|
||||
for i in range(colors.size()):
|
||||
for j in range(i + 1, colors.size()):
|
||||
assert_that(colors[i] != colors[j]).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: Relationship-to-Color Mapping
|
||||
# Tests the lookup function that maps relationship strings to D-033 colors.
|
||||
# Depends on #521 adding color_for_relationship() to Constants.
|
||||
# Uses ConstantsScript method list to skip gracefully if not yet implemented.
|
||||
# ==============================================================================
|
||||
|
||||
func _has_color_for_relationship() -> bool:
|
||||
# Check if the Constants script has a color_for_relationship method.
|
||||
for method in ConstantsScript.get_script_method_list():
|
||||
if method.name == "color_for_relationship":
|
||||
return true
|
||||
return false
|
||||
|
||||
func test_relationship_color_unknown() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
push_warning("TestColorShift: color_for_relationship not implemented yet — awaiting #521")
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("Unknown")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
func test_relationship_color_friendly() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("Friendly")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
|
||||
func test_relationship_color_person_of_interest() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("PersonOfInterest")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
func test_relationship_color_hostile() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("Hostile")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
func test_relationship_color_fallback() -> void:
|
||||
if not _has_color_for_relationship():
|
||||
return
|
||||
var color: Color = Constants.color_for_relationship("SomethingWeird")
|
||||
assert_that(color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Entity Renderer — Relationship-Based Coloring
|
||||
# Tests that entity_renderer uses the relationship field for NPC colors.
|
||||
# Player and Object entities should remain unaffected by relationship field.
|
||||
# ==============================================================================
|
||||
|
||||
func test_npc_uses_relationship_color_unknown() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_friendly() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_poi() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_npc_uses_relationship_color_hostile() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_player_color_ignores_relationship() -> void:
|
||||
# Player entity always uses ENTITY_COLOR_PLAYER regardless of relationship
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_object_color_ignores_relationship() -> void:
|
||||
# Object entities always use ENTITY_COLOR_OBJECT
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
|
||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Tween on Relationship Change
|
||||
# Tests that color transitions use a 0.5-1s fade, not an instant flip.
|
||||
# D-033: "Color shifts smoothly (0.5s fade) when relationship state changes."
|
||||
# D-063: "entity D-033 color may fade" on confrontation delivery.
|
||||
# ==============================================================================
|
||||
|
||||
func test_color_shift_not_instant() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||
renderer.update_entities(entities_before)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
# Change relationship to Hostile
|
||||
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||
renderer.update_entities(entities_after)
|
||||
# Immediately after update, color should NOT yet be the target
|
||||
var color_after_immediate: Color = node.color
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
||||
renderer.queue_free()
|
||||
return
|
||||
# The color should NOT be exactly the target yet (tween in progress)
|
||||
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_color_shift_reaches_target() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
|
||||
# Simulate time passing: ~1.5 seconds of frames
|
||||
var elapsed: float = 0.0
|
||||
while elapsed < 1.5:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_color_shift_mid_transition_retrigger() -> void:
|
||||
# Rapid relationship changes: Unknown → Friendly → Hostile in quick succession.
|
||||
# The second change should preempt the first tween and converge to Hostile.
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
if not _renderer_has_tween_support(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Unknown")])
|
||||
# First change: Unknown → Friendly
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Friendly")])
|
||||
# Advance partway (0.1s of a 0.5s tween)
|
||||
for i in range(6):
|
||||
renderer._process(1.0 / 60.0)
|
||||
# Second change mid-tween: Friendly → Hostile (preempts first)
|
||||
renderer.update_entities([_make_entity(2, "Npc", "Hostile")])
|
||||
# Advance past full duration
|
||||
var elapsed: float = 0.0
|
||||
while elapsed < 1.0:
|
||||
renderer._process(1.0 / 60.0)
|
||||
elapsed += 1.0 / 60.0
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_color_shift_same_relationship_no_tween() -> void:
|
||||
var renderer: Node2D = _make_entity_renderer()
|
||||
if not _entity_uses_relationship(renderer):
|
||||
renderer.queue_free()
|
||||
return
|
||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||
renderer.update_entities(entities)
|
||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
||||
var color_first: Color = node.color
|
||||
renderer.update_entities(entities)
|
||||
var color_second: Color = node.color
|
||||
assert_that(color_first).is_equal(color_second)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: Protocol — Relationship Field Round-Trip
|
||||
# ==============================================================================
|
||||
|
||||
func test_protocol_entity_relationship_decoded() -> void:
|
||||
var raw: Dictionary = {
|
||||
"entity_id": 5,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": "Npc",
|
||||
"relationship": "PersonOfInterest",
|
||||
"visibility": "Forward",
|
||||
"observation": "Visible",
|
||||
}
|
||||
var decoded: Variant = Protocol._decode_entity(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.relationship).is_equal("PersonOfInterest")
|
||||
|
||||
func test_protocol_entity_relationship_defaults_unknown() -> void:
|
||||
var raw: Dictionary = {
|
||||
"entity_id": 5,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": "Npc",
|
||||
}
|
||||
var decoded: Variant = Protocol._decode_entity(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.relationship).is_equal("Unknown")
|
||||
|
||||
func test_protocol_entity_all_relationship_values() -> void:
|
||||
var relationships: Array[String] = ["Unknown", "Friendly", "PersonOfInterest", "Hostile"]
|
||||
for rel in relationships:
|
||||
var raw: Dictionary = {
|
||||
"entity_id": 5,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": "Npc",
|
||||
"relationship": rel,
|
||||
}
|
||||
var decoded: Variant = Protocol._decode_entity(raw)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.relationship).is_equal(rel)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: GameState — Relationship Data Passthrough
|
||||
# ==============================================================================
|
||||
|
||||
func test_game_state_preserves_relationship() -> void:
|
||||
var snapshot: Dictionary = {
|
||||
"tick": 1,
|
||||
"entities": [_make_entity(2, "Npc", "Friendly")],
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
assert_that(GameState.visible_entities.size()).is_equal(1)
|
||||
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
|
||||
|
||||
func test_game_state_relationship_changes_between_snapshots() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [_make_entity(2, "Npc", "Friendly")]})
|
||||
assert_that(GameState.visible_entities[0].relationship).is_equal("Friendly")
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [_make_entity(2, "Npc", "PersonOfInterest")]})
|
||||
assert_that(GameState.visible_entities[0].relationship).is_equal("PersonOfInterest")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Helpers
|
||||
# ==============================================================================
|
||||
|
||||
func _entity_uses_relationship(renderer: Node2D) -> bool:
|
||||
var friendly: Dictionary = _make_entity(10, "Npc", "Friendly", 3.0, 3.0)
|
||||
var hostile: Dictionary = _make_entity(11, "Npc", "Hostile", 5.0, 5.0)
|
||||
renderer.update_entities([friendly, hostile])
|
||||
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
||||
return false
|
||||
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
|
||||
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
|
||||
var f_color: Color = f_node.color
|
||||
var h_color: Color = h_node.color
|
||||
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
||||
renderer.update_entities([])
|
||||
return uses_rel
|
||||
|
||||
func _renderer_has_tween_support(renderer: Node2D) -> bool:
|
||||
return renderer.get("_entity_tweens") != null
|
||||
@@ -305,6 +305,46 @@ func test_cursor_changes_require_los() -> void:
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
# -- OQ-07: Insert-off behavior (D-056 amendment, #522) ----------------------
|
||||
# Option (a): cursor shape still changes, verb labels suppressed.
|
||||
|
||||
func test_insert_off_cursor_still_changes_shape() -> void:
|
||||
# OQ-07 option (a): cursor state machine still fires when insert is off.
|
||||
# The character's body orients toward targets even without insert data.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if cursor.has_method("set_insert_active") and cursor.has_method("set_hover_target"):
|
||||
cursor.set_insert_active(false)
|
||||
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
|
||||
assert_that(str(cursor.get_state())).is_equal("EntityHover")
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
func test_insert_off_suppresses_interactions() -> void:
|
||||
# OQ-07 option (a): with insert off, should_show_interactions() returns false.
|
||||
# Verb labels (z-layer 6) are suppressed — no actionable insert data.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
|
||||
cursor.set_insert_active(false)
|
||||
assert_that(cursor.should_show_interactions()).is_false()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
func test_insert_on_restores_interaction_display() -> void:
|
||||
# Re-enabling insert allows interactions to show again.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if cursor.has_method("set_insert_active") and cursor.has_method("should_show_interactions"):
|
||||
cursor.set_insert_active(false)
|
||||
cursor.set_insert_active(true)
|
||||
assert_that(cursor.should_show_interactions()).is_true()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
# -- Interaction range (D-056) ------------------------------------------------
|
||||
|
||||
func test_click_interaction_range() -> void:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://mg1wnv1ydc6m
|
||||
@@ -0,0 +1 @@
|
||||
uid://foiy3bk1jko7
|
||||
@@ -0,0 +1,288 @@
|
||||
## #501: Hub teleport client UX — QA test suite
|
||||
## Spec refs: D-020 (protocol), D-030 (testability)
|
||||
## Sprint Completion Proof (joint.md):
|
||||
## - Home key sends TeleportToHub in Gauntlet mode
|
||||
## - 0.3s fade-to-black-and-back plays on teleport
|
||||
## - Dialogue/monologue/interaction buffer cleared on teleport
|
||||
## - Non-Gauntlet: action rejected, client shows no effect
|
||||
class_name TestHubTeleport
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Fixtures ------------------------------------------------------------------
|
||||
|
||||
var _gauntlet_snapshot := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
|
||||
],
|
||||
"tiles": [],
|
||||
"visible_tiles": [],
|
||||
"visible_positions": [],
|
||||
"nearby_interactions": [],
|
||||
"current_monologue": null,
|
||||
"current_dialogue": null,
|
||||
"pending_recognitions": [],
|
||||
"gauntlet_mode": true,
|
||||
"room_id": "proof_room",
|
||||
}
|
||||
|
||||
var _normal_snapshot := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 50.0, "y": 50.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"},
|
||||
],
|
||||
"tiles": [],
|
||||
"visible_tiles": [],
|
||||
"visible_positions": [],
|
||||
"nearby_interactions": [],
|
||||
"current_monologue": null,
|
||||
"current_dialogue": null,
|
||||
"pending_recognitions": [],
|
||||
}
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.room_id = null
|
||||
InputMapper.input_queue.clear()
|
||||
SimBridge.reset_test_state()
|
||||
|
||||
|
||||
# -- InputMapper: TELEPORT_HUB action enum ------------------------------------
|
||||
|
||||
func test_teleport_hub_action_exists() -> void:
|
||||
# Verify the enum value exists and is distinct
|
||||
var action: int = InputMapper.Action.TELEPORT_HUB
|
||||
assert_that(action).is_not_equal(InputMapper.Action.INTERACT)
|
||||
assert_that(action).is_not_equal(InputMapper.Action.MOVE_NORTH)
|
||||
|
||||
|
||||
# -- InputMapper: Gauntlet mode guard -----------------------------------------
|
||||
|
||||
func test_teleport_hub_blocked_outside_gauntlet() -> void:
|
||||
# Non-gauntlet: Home key input should NOT queue TELEPORT_HUB
|
||||
GameState.gauntlet_mode = false
|
||||
InputMapper.input_queue.clear()
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = KEY_HOME
|
||||
event.pressed = true
|
||||
InputMapper._unhandled_input(event)
|
||||
var has_teleport := false
|
||||
for entry in InputMapper.input_queue:
|
||||
if entry.action == InputMapper.Action.TELEPORT_HUB:
|
||||
has_teleport = true
|
||||
assert_that(has_teleport).is_false()
|
||||
|
||||
|
||||
func test_teleport_hub_allowed_in_gauntlet_mode() -> void:
|
||||
# Gauntlet mode: Home key SHOULD queue TELEPORT_HUB
|
||||
GameState.gauntlet_mode = true
|
||||
InputMapper.input_queue.clear()
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = KEY_HOME
|
||||
event.pressed = true
|
||||
InputMapper._unhandled_input(event)
|
||||
var has_teleport := false
|
||||
for entry in InputMapper.input_queue:
|
||||
if entry.action == InputMapper.Action.TELEPORT_HUB:
|
||||
has_teleport = true
|
||||
assert_that(has_teleport).is_true()
|
||||
|
||||
|
||||
# -- GameState: gauntlet_mode from snapshot ------------------------------------
|
||||
|
||||
func test_gauntlet_mode_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot(_gauntlet_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
assert_that(GameState.room_id).is_equal("proof_room")
|
||||
|
||||
|
||||
func test_gauntlet_mode_false_when_absent() -> void:
|
||||
GameState.apply_snapshot(_normal_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_false()
|
||||
assert_that(GameState.room_id).is_null()
|
||||
|
||||
|
||||
func test_gauntlet_mode_transitions_off() -> void:
|
||||
# Gauntlet on → off: mode should clear
|
||||
GameState.apply_snapshot(_gauntlet_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
GameState.apply_snapshot(_normal_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_false()
|
||||
|
||||
|
||||
# -- SimBridge: wire format encoding -------------------------------------------
|
||||
|
||||
func test_teleport_hub_wire_name() -> void:
|
||||
# TELEPORT_HUB must encode to "TeleportToHub" on the wire (matching Rust PlayerAction)
|
||||
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
|
||||
assert_that(wire_name).is_equal("TeleportToHub")
|
||||
|
||||
|
||||
func test_teleport_hub_wire_not_empty() -> void:
|
||||
# Wire name must not be empty (empty = client-only, not sent to server)
|
||||
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
|
||||
assert_that(wire_name.is_empty()).is_false()
|
||||
|
||||
|
||||
func test_teleport_hub_encode_roundtrip() -> void:
|
||||
# Verify MessagePack encode→decode roundtrip for TeleportToHub
|
||||
var encoded: PackedByteArray = Protocol.encode_player_input(42, "TeleportToHub")
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
var decoded: Variant = Protocol.decode_player_input(encoded)
|
||||
assert_that(decoded).is_not_null()
|
||||
assert_that(decoded.tick).is_equal(42)
|
||||
assert_that(decoded.action.variant).is_equal("TeleportToHub")
|
||||
assert_that(decoded.action.data).is_null()
|
||||
|
||||
|
||||
# -- SimBridge: test mode teleport behavior ------------------------------------
|
||||
|
||||
func test_test_mode_teleport_resets_position() -> void:
|
||||
# In test mode, TeleportToHub should reset player to hub spawn (10, 10)
|
||||
SimBridge.reset_test_state()
|
||||
# Move player away first
|
||||
SimBridge._test_player_pos = Vector2i(50, 50)
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
# Player should be back at hub spawn
|
||||
var player_entity: Dictionary = snap.entities[0]
|
||||
assert_that(player_entity.x).is_equal(10.0)
|
||||
assert_that(player_entity.y).is_equal(10.0)
|
||||
|
||||
|
||||
func test_test_mode_teleport_clears_dialogue() -> void:
|
||||
# TeleportToHub in test mode should clear dialogue state
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_in_dialogue = true
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
SimBridge._test_snapshot()
|
||||
assert_that(SimBridge._test_in_dialogue).is_false()
|
||||
|
||||
|
||||
# -- Teleport detection -------------------------------------------------------
|
||||
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
|
||||
# These tests verify the distance math against that threshold.
|
||||
|
||||
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
func test_detect_teleport_large_jump() -> void:
|
||||
# Position jump > threshold should be detected as teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(50.0, 50.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
|
||||
|
||||
|
||||
func test_detect_teleport_normal_movement() -> void:
|
||||
# Normal 1-tile movement should NOT be detected as teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(11.0, 10.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
|
||||
|
||||
|
||||
func test_detect_teleport_diagonal_movement() -> void:
|
||||
# Diagonal movement (1,1) — distance ~1.41, not a teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(11.0, 11.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
|
||||
|
||||
|
||||
func test_detect_teleport_boundary_exactly_threshold() -> void:
|
||||
# Exactly threshold — should NOT trigger (> not >=)
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(15.0, 10.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_false()
|
||||
|
||||
|
||||
func test_detect_teleport_boundary_just_over() -> void:
|
||||
# Just over threshold — should trigger
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
var new_pos := Vector2(15.1, 10.0)
|
||||
assert_that(old_pos.distance_to(new_pos) > _THRESHOLD).is_true()
|
||||
|
||||
|
||||
# -- Buffer clearing on teleport -----------------------------------------------
|
||||
|
||||
func test_teleport_clears_dialogue_in_test_mode() -> void:
|
||||
# TeleportToHub in test mode should clear dialogue state via the pipeline
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_in_dialogue = true
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
# Dialogue should be cleared by teleport
|
||||
assert_that(SimBridge._test_in_dialogue).is_false()
|
||||
assert_that(snap.current_dialogue).is_null()
|
||||
|
||||
|
||||
func test_teleport_resets_position_in_test_mode() -> void:
|
||||
# TeleportToHub must reset to hub spawn and clear dialogue (integration)
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_player_pos = Vector2i(50, 50)
|
||||
SimBridge._test_in_dialogue = true
|
||||
SimBridge._test_input_queue.append("TeleportToHub")
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
var player: Dictionary = snap.entities[0]
|
||||
assert_that(player.x).is_equal(10.0)
|
||||
assert_that(player.y).is_equal(10.0)
|
||||
assert_that(SimBridge._test_in_dialogue).is_false()
|
||||
|
||||
|
||||
# -- Send input integration (test mode) ----------------------------------------
|
||||
|
||||
func test_send_teleport_hub_in_test_mode() -> void:
|
||||
# Verify send_input accepts TELEPORT_HUB in test mode
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge.state = SimBridge.ConnectionState.CONNECTED
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.TELEPORT_HUB,
|
||||
"timestamp_msec": 12345,
|
||||
})
|
||||
assert_that(err).is_equal(OK)
|
||||
|
||||
|
||||
func test_send_teleport_hub_queues_wire_action() -> void:
|
||||
# Verify TELEPORT_HUB is queued as "TeleportToHub" in test mode
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge.state = SimBridge.ConnectionState.CONNECTED
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.TELEPORT_HUB,
|
||||
"timestamp_msec": 12345,
|
||||
})
|
||||
assert_that(SimBridge._test_input_queue.has("TeleportToHub")).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_mode_from_test_snapshot() -> void:
|
||||
# Verify test snapshot includes gauntlet_mode field
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._test_gauntlet_mode = true
|
||||
var snap: Dictionary = SimBridge._test_snapshot()
|
||||
assert_that(snap.gauntlet_mode).is_true()
|
||||
SimBridge._test_gauntlet_mode = false
|
||||
snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.gauntlet_mode).is_false()
|
||||
|
||||
|
||||
# -- Live mode outbound encoding -----------------------------------------------
|
||||
|
||||
func test_teleport_hub_outbound_entry() -> void:
|
||||
# In live mode, TELEPORT_HUB should produce a valid outbound buffer entry
|
||||
# (We can't test full live mode in unit tests, but we test the encode path)
|
||||
var encoded: PackedByteArray = Protocol.encode_player_input(100, "TeleportToHub")
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
# Decode and verify
|
||||
var decoded: Variant = Protocol.decode_player_input(encoded)
|
||||
assert_that(decoded.action.variant).is_equal("TeleportToHub")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user