Compare 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,17 @@
|
||||
"Bash(find *)",
|
||||
"Bash(list *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(sed -n *)",
|
||||
|
||||
"Skill(commit)",
|
||||
"Skill(git-commit)",
|
||||
"Skill(worktree-update)",
|
||||
"Skill(start-sprint)"
|
||||
"Skill(sprint-start)",
|
||||
"Skill(sprint-plan)",
|
||||
"Skill(pr-push)",
|
||||
"Skill(pr-review)",
|
||||
"Skill(ticket)",
|
||||
"Skill(docs-search)",
|
||||
"Skill(workshop-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,9 +1,9 @@
|
||||
---
|
||||
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.
|
||||
@@ -41,7 +41,7 @@ git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes (staged or unstaged), run the **commit skill**
|
||||
first. Use the `/commit` skill to group changes into logical commits with
|
||||
first. Use the `/git-commit` skill to group changes into logical commits with
|
||||
proper conventional commit messages. Wait for commit to complete before
|
||||
proceeding.
|
||||
|
||||
@@ -136,5 +136,5 @@ already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
|
||||
## 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
|
||||
@@ -156,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 —
|
||||
@@ -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"
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: start-sprint
|
||||
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 /start-sprint. On main:
|
||||
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.
|
||||
@@ -34,7 +34,7 @@ the team branch workflow (steps 2–8).
|
||||
|
||||
## Main branch workflow (sprint lifecycle management)
|
||||
|
||||
When `/start-sprint` is run on `main`, assess the current sprint state
|
||||
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
|
||||
@@ -45,7 +45,38 @@ Then follow the **first matching case**:
|
||||
|
||||
### Case A: An active sprint exists
|
||||
|
||||
The active sprint needs to be closed before moving on.
|
||||
First, check whether the sprint's work is actually done:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint status
|
||||
```
|
||||
|
||||
This shows ticket counts by status (done, in_progress, backlog).
|
||||
|
||||
Also check for open PRs that may contain completed work waiting for
|
||||
review or merge:
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Report the full picture to the user:
|
||||
- Ticket progress (done / in_progress / backlog counts)
|
||||
- Open PRs (if any — these represent work that's done but not merged)
|
||||
|
||||
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
|
||||
exist**, do NOT close the sprint. Instead, report the current progress
|
||||
and ask the user what they want to do:
|
||||
|
||||
- **Continue working** — switch to a team branch and run `/sprint-start`
|
||||
there to resume work
|
||||
- **Review & merge PRs first** — (only if open PRs exist) merge pending
|
||||
work before deciding whether to close
|
||||
- **Close anyway** — proceed with the close workflow below (carries over
|
||||
unfinished tickets)
|
||||
|
||||
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
|
||||
explicitly chooses to close.
|
||||
|
||||
#### A1. Close the active sprint
|
||||
|
||||
@@ -97,7 +128,7 @@ git push && git push --tags
|
||||
|
||||
After closing, re-query the database. If a sprint in `planning` status
|
||||
exists, continue to **Case B**. Otherwise, report the close and suggest
|
||||
running `/plan-sprint`.
|
||||
running `/sprint-plan`.
|
||||
|
||||
---
|
||||
|
||||
@@ -115,7 +146,7 @@ A sprint is ready to activate. Verify it looks complete:
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
and suggest running `/plan-sprint` to complete planning.
|
||||
and suggest running `/sprint-plan` to complete planning.
|
||||
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
@@ -125,14 +156,14 @@ db/connectors/sprint start
|
||||
|
||||
Then report:
|
||||
- Sprint activated (name, ticket count per team)
|
||||
- Remind the user to switch to a team branch and run `/start-sprint`
|
||||
- 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 `/plan-sprint`
|
||||
Nothing is ready. Report the state and suggest running `/sprint-plan`
|
||||
to plan the next sprint.
|
||||
|
||||
---
|
||||
@@ -167,7 +198,7 @@ If no active sprint is found, report that and stop.
|
||||
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
|
||||
`/plan-sprint` to generate one.
|
||||
`/sprint-plan` to generate one.
|
||||
|
||||
### 5. Load ticket details
|
||||
|
||||
@@ -257,6 +288,17 @@ Task(
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -116,12 +116,10 @@ git merge origin/main --no-edit
|
||||
If clean, report the result (fast-forward or merge commit, files changed).
|
||||
If conflicts, report them and stop.
|
||||
|
||||
### 3. Push prompt
|
||||
### 3. Push
|
||||
|
||||
After a successful merge, ask the user if they want to push:
|
||||
After a successful merge, push the branch:
|
||||
|
||||
```bash
|
||||
git push origin <current-branch>
|
||||
```
|
||||
|
||||
Never push without explicit confirmation.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Build and cache
|
||||
.cache/
|
||||
.tmp/
|
||||
server/target/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
|
||||
+120
@@ -6,6 +6,121 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.14] — 2026-02-21
|
||||
|
||||
### Added
|
||||
- Unified dialogue log — player-NPC and overheard NPC-NPC conversations in one chronological scrolling panel (#535, D-061/D-078)
|
||||
- F3 debug overlay — real-time game state display with tick, FPS, position, entity counts, dialogue/monologue status (#511)
|
||||
- Monologue display — multi-line priority queue with character colours, italic BBCode, stagger animation (#122)
|
||||
- Protocol v9 — conversation_events, conversation_ended, dialogue_response fields with carry-forward logic
|
||||
- Dialogue theme system — configurable NPC name colour palette, entry timing, passive opacity via dialogue-theme.yaml
|
||||
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
|
||||
- Entity color system spec — D-033 relationship-to-player mapping, transition animations, color blindness assessment (#304)
|
||||
- Text display hierarchy spec — 4 content pipelines (dialogue, monologue, observation, environmental) with z-layers and positioning (#316)
|
||||
- Sound indicator visual design — fog-edge pulse for D-018 three-range sound model with direction encoding and range differentiation (#317)
|
||||
- THE FRIEND visual treatment spec — 3-phase earned visual detail for Kael Davan and Sera Venn (#318)
|
||||
- Environmental text visual standards — signage, terminal, and news ticker rendering with bilingual Concordat/Krenn treatment (#334)
|
||||
- Tell visual/behavioral expression spec — 5 tell categories mapped to 6 Tier 2 behaviors (#251)
|
||||
- Monologue line pool maxLength raised from 160 to 256 chars (soft guidance ≤160)
|
||||
- NPC name masking infrastructure — entity-anchored dialogue log with server-side role labels, retroactive name update on learning, NpcColorIndex for stable color assignment
|
||||
- Dialogue option keyboard selection (1/2/3 number keys) and numbered option labels
|
||||
- Interaction list chrome — background panel, mouse hover highlighting, click-to-interact, pointing hand cursor
|
||||
|
||||
### Changed
|
||||
- D-061 updated to document unified conversation log architecture from Sprint 14
|
||||
- Dialogue options switched from RichTextLabel to Label for reliable VBoxContainer sizing
|
||||
|
||||
### Fixed
|
||||
- Visual grammar dialogue max-width corrected from "~70% screen width" to 640px per D-076
|
||||
- BBCode injection in dialogue log formatting — server-sourced strings now escaped with [lb]
|
||||
- Per-frame dialogue log rebuild replaced with dirty flag (performance)
|
||||
- dialogue_active lifecycle — now cleared after panel fade completes per D-064
|
||||
- PAUSE/UNPAUSE routed through main.gd input recording for bug report replay (#507)
|
||||
- WASD input freeze after filing bug report — LineEdit focus not released before queue_free() across CanvasLayers
|
||||
- WASD not reactivating after Talk — dialogue_active held for entry_lifetime instead of cleared immediately
|
||||
- Recognition chime spam — entity IDs now tracked permanently per room instead of expiring
|
||||
- Audio path warning — res://audio/ corrected to res://assets/audio/ in AudioManager
|
||||
- world_radial.tscn anchors_preset warning — changed from 15 to 0
|
||||
- bug_report_dialog.gd push_warning changed to print for informational message
|
||||
|
||||
## [v0.1.13] — 2026-02-20
|
||||
|
||||
### Added
|
||||
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
|
||||
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
|
||||
|
||||
## [v0.1.12] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- Tier marker components (#93), active tier simulation (#94), tier transition logic (#99)
|
||||
- Information tag schema (#138), component-level access control (#139)
|
||||
- Line previewer CLI (#193)
|
||||
- Sound event system — server pipeline (#124)
|
||||
- Close-range stereo audio — client positional 2D (#125)
|
||||
- Medium-range visual indicators — fog-edge directional arrows (#126)
|
||||
- HashMap ban in simulation crate via clippy (#343)
|
||||
- Tracing crate infrastructure — JSON format, tick duration logging (#344)
|
||||
- System dependency graph debug command — `--dump-schedule` CLI flag (#346)
|
||||
- rng_seed field on ObserverSnapshot for deterministic replay (#527)
|
||||
- v0.1 Visual Grammar Document (#303)
|
||||
- Placeholder art specification (#252)
|
||||
- Spatial layouts: Logistics Hub (#311), Bar (#312), Smuggling corridors (#313)
|
||||
- Cultural generation guide — 5-dimension framework for Sova Transit District cultural voice (#189)
|
||||
- Sova Texture Appendix — 20-term slang glossary, sensory profile, Meridian self-censorship rules (#302)
|
||||
- Contraband specification — unlicensed lattice components, supply chain, street terminology (#321)
|
||||
- Sova Station Profile — 6 districts, governance, off-station references (#320)
|
||||
- Span Gate Transit Schedule — hourly schedule, maintenance windows, ring operational calendar (#336)
|
||||
- Meridian Coverage Map — 10 named zones from Commission-grade to dead air (#335)
|
||||
- Character definition schema and both character builds — smuggler + detective (#179, #180, #181)
|
||||
- Divergent starting knowledge and relationships per character (#182, #183)
|
||||
- Detective institutional chain of command (#322)
|
||||
- Contradiction arc design document — reusable FRIEND pattern (#332)
|
||||
- Mirror moment design document — 7 core dual-perspective observation triggers (#329)
|
||||
- First 5 minutes experience design — systemic opening per character (#259)
|
||||
- Opening hook content per character (#260)
|
||||
- Knowledge vocabulary for v0.1 content — entity/world categories, prerequisite format (#368)
|
||||
- Knowledge state vocabulary — author-facing quick reference (#309)
|
||||
- Knowledge fact catalogs — 10 YAML files in content/global/knowledge/, 73 canonical facts
|
||||
- D-075 endorsement — archetype dimension review recorded in decisions/content.md
|
||||
- Flat NPC memorable trait pass — Pael, Ren, Tev with noise-floor profiles (#307)
|
||||
- Environmental text content — 20 items across Terminal, Bar, and Corridors with dual-lens notes (#262)
|
||||
- Diegetic insert flavor text — per-character labels and notification strings (#331)
|
||||
- News ticker / Meridian feed — 30 lines including batch 44xx recall dual-lens moment (#306)
|
||||
- Workplace content pack — The Terminal: 5 NPC dialogue files (#190)
|
||||
- Bar content pack — The Last Shift: 3 NPC dialogue files (#191)
|
||||
- Smuggling ring content pack — maintenance corridors: coded vocabulary, dual registers (#192)
|
||||
- Generation pass expansion — 80 ambient variant lines across all 9 dialogue files (#194)
|
||||
- Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual teams; full audio architecture + gauntlet expansion + monologue display spec
|
||||
|
||||
### Fixed
|
||||
- Entity renderer field name bug — `id` vs `entity_id` (#345)
|
||||
- Dialogue max-width pixel value — 640px per D-076 (#447)
|
||||
- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker
|
||||
- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering)
|
||||
- Stale protocol version doc comment "Current: 9" corrected to 10
|
||||
- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying
|
||||
- SOUND_EVENT_ASSETS walk-speed key mismatch — `sfx_footstep_metal` corrected to `sfx_footstep_metal_walk`
|
||||
|
||||
### Changed
|
||||
- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests)
|
||||
- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired)
|
||||
- Observer test setup now inserts SoundEventQueue resource for integration coverage
|
||||
- Added FactionOnly positive test case and Medium-range occlusion TODO
|
||||
- Sound indicator colors sourced from Constants instead of duplicated hex literals
|
||||
- play_loop() null guard on stream.duplicate()
|
||||
- Camera zoom fallback uses Constants.CAMERA_DEFAULT_ZOOM
|
||||
|
||||
## [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
|
||||
@@ -17,12 +132,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- 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`
|
||||
|
||||
@@ -35,7 +35,7 @@ db/
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
@@ -132,6 +132,12 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline
|
||||
perf-baseline debug-schedule
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -53,6 +53,7 @@ help:
|
||||
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
|
||||
@echo ""
|
||||
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
|
||||
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
|
||||
@echo ""
|
||||
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
|
||||
|
||||
@@ -285,6 +286,12 @@ checklist-generate:
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
# --- Schedule debug (#346) ---
|
||||
|
||||
debug-schedule:
|
||||
@echo "Dumping bevy_ecs schedule graph..."
|
||||
@cd server && cargo run -- --dump-schedule
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
|
||||
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,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://caeuztw9ah5h5l"
|
||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_npc_murmur.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,47 @@
|
||||
# Dialogue Log Theme — The Settled Reach
|
||||
#
|
||||
# Ticket: #535 | Sprint: 14
|
||||
# Colors and timing for the unified dialogue log panel.
|
||||
# Loaded by dialogue_box.gd at runtime.
|
||||
|
||||
# ============================================================
|
||||
# PLAYER COLOR
|
||||
# Fixed color for the player's name in dialogue log entries.
|
||||
# ============================================================
|
||||
player_color: "#e0e8ff"
|
||||
|
||||
# ============================================================
|
||||
# NPC COLOR PALETTE
|
||||
# 8 distinct colors for NPC names. Indexed by hash(npc_name) % 8.
|
||||
# Must be readable on a dark semi-transparent panel background.
|
||||
# ============================================================
|
||||
npc_colors:
|
||||
0: "#4a9ebb" # teal
|
||||
1: "#6bc9a6" # green
|
||||
2: "#e8c547" # amber
|
||||
3: "#d49e5d" # warm orange
|
||||
4: "#b586d4" # lavender
|
||||
5: "#d45d5d" # muted red
|
||||
6: "#5daa7d" # forest
|
||||
7: "#7daccc" # sky blue
|
||||
|
||||
# ============================================================
|
||||
# TEXT COLORS
|
||||
# Arrow separator and speech text color.
|
||||
# ============================================================
|
||||
arrow_color: "#8890a0"
|
||||
speech_color: "#c8d0e0"
|
||||
|
||||
# ============================================================
|
||||
# PASSIVE (OVERHEARD) OPACITY
|
||||
# Base opacity multiplier for non-player-centric lines.
|
||||
# 1.0 = full opacity, 0.0 = invisible.
|
||||
# ============================================================
|
||||
passive_opacity: 0.9
|
||||
|
||||
# ============================================================
|
||||
# ENTRY TIMING
|
||||
# All entry types share the same lifetime and fade duration.
|
||||
# ============================================================
|
||||
entry_lifetime_seconds: 15.0
|
||||
entry_fade_seconds: 3.0
|
||||
@@ -0,0 +1,43 @@
|
||||
[gd_resource type="AudioBusLayout" format=3]
|
||||
|
||||
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
|
||||
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
|
||||
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
|
||||
|
||||
[resource]
|
||||
bus/0/name = "Master"
|
||||
bus/0/solo = false
|
||||
bus/0/mute = false
|
||||
bus/0/bypass_fx = false
|
||||
bus/0/volume_db = 0.0
|
||||
bus/0/send = &""
|
||||
bus/1/name = "Music"
|
||||
bus/1/solo = false
|
||||
bus/1/mute = false
|
||||
bus/1/bypass_fx = false
|
||||
bus/1/volume_db = 0.0
|
||||
bus/1/send = &"Master"
|
||||
bus/2/name = "Ambient"
|
||||
bus/2/solo = false
|
||||
bus/2/mute = false
|
||||
bus/2/bypass_fx = false
|
||||
bus/2/volume_db = 0.0
|
||||
bus/2/send = &"Master"
|
||||
bus/3/name = "WorldSFX"
|
||||
bus/3/solo = false
|
||||
bus/3/mute = false
|
||||
bus/3/bypass_fx = false
|
||||
bus/3/volume_db = 0.0
|
||||
bus/3/send = &"Master"
|
||||
bus/4/name = "PlayerActions"
|
||||
bus/4/solo = false
|
||||
bus/4/mute = false
|
||||
bus/4/bypass_fx = false
|
||||
bus/4/volume_db = 0.0
|
||||
bus/4/send = &"Master"
|
||||
bus/5/name = "UISounds"
|
||||
bus/5/solo = false
|
||||
bus/5/mute = false
|
||||
bus/5/bypass_fx = false
|
||||
bus/5/volume_db = 0.0
|
||||
bus/5/send = &"Master"
|
||||
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
buses/default_bus_layout="res://default_bus_layout.tres"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="res://assets/theme/game_theme.tres"
|
||||
@@ -116,6 +120,11 @@ bug_report={
|
||||
"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)
|
||||
]
|
||||
}
|
||||
debug_overlay={
|
||||
"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":4194334,"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)
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=23 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,9 +16,12 @@
|
||||
[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="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"]
|
||||
[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"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -96,6 +99,13 @@ script = ExtResource("4_fog")
|
||||
z_index = 950
|
||||
script = ExtResource("16_fogent")
|
||||
|
||||
; --- z:951 — Medium-range sound indicators (#126, D-018) ---
|
||||
; Directional arrows at fog boundary for sounds outside LOS.
|
||||
; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10).
|
||||
[node name="SoundIndicators" type="Node2D" parent="World"]
|
||||
z_index = 951
|
||||
script = ExtResource("20_soundind")
|
||||
|
||||
; --- Camera ---
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position_smoothing_enabled = true
|
||||
@@ -143,6 +153,16 @@ layer = 20
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
; #511: F3 debug overlay — real-time game state, toggled by F3
|
||||
[node name="DebugOverlay" type="Control" parent="UILayer"]
|
||||
anchors_preset = 0
|
||||
offset_left = 16
|
||||
offset_top = 120
|
||||
offset_right = 400
|
||||
offset_bottom = 400
|
||||
mouse_filter = 2
|
||||
script = ExtResource("22_debug")
|
||||
|
||||
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
|
||||
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
|
||||
script = ExtResource("10_cursor")
|
||||
@@ -154,3 +174,6 @@ layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
||||
|
||||
@@ -5,6 +5,11 @@ extends Node
|
||||
## No-op fallback when audio assets absent (D-038).
|
||||
## Spatial audio positioning for close-range sounds (D-018).
|
||||
|
||||
# --- D-067: Recognition chime asset key ---
|
||||
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
const CHIME_RECOGNITION := "sfx_monologue_chime"
|
||||
|
||||
# --- Bus names (D-068) ---
|
||||
const BUS_MUSIC := "Music"
|
||||
const BUS_AMBIENT := "Ambient"
|
||||
@@ -36,6 +41,23 @@ const DIP_SPECS := {
|
||||
# Low-pass filter default cutoff — effectively bypassed at this value.
|
||||
const FILTER_CUTOFF_DEFAULT := 20500.0
|
||||
|
||||
# --- D-073: Zone crossfade constants ---
|
||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
|
||||
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
|
||||
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
const ZONE_ASSETS: Dictionary = {
|
||||
"hub": "amb_hub_layer",
|
||||
"workplace": "amb_hub_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
@@ -52,12 +74,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
|
||||
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
|
||||
var _ambient_players: Dictionary = {}
|
||||
|
||||
# D-073: Zone crossfade state
|
||||
var _current_zone_id: String = ""
|
||||
var _zone_tweens: Array = []
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_scan_registry()
|
||||
_load_prefs()
|
||||
|
||||
|
||||
# --- Bus setup ---
|
||||
@@ -105,6 +134,7 @@ func _scan_dir(path: String) -> void:
|
||||
if stream:
|
||||
_registry[file_name.get_basename()] = stream
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func has_asset(asset_key: String) -> bool:
|
||||
@@ -137,6 +167,8 @@ func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlaye
|
||||
return null
|
||||
stop_loop(asset_key)
|
||||
var loop_stream := stream.duplicate() as AudioStream
|
||||
if loop_stream == null:
|
||||
return null
|
||||
_enable_loop(loop_stream)
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.stream = loop_stream
|
||||
@@ -163,6 +195,32 @@ func stop_all_loops() -> void:
|
||||
stop_loop(key)
|
||||
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
|
||||
## Play a close-range sound event at a world tile position (D-018, #125).
|
||||
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
|
||||
## world_tile_pos: server tile coordinates — converted to world pixels internally.
|
||||
## No-ops if event_type has no registered asset or asset file is absent.
|
||||
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
|
||||
if asset_key.is_empty():
|
||||
return
|
||||
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
## Play a one-shot spatial sound at a world position (pixels).
|
||||
@@ -270,19 +328,85 @@ func set_volume(bus: String, volume_db: float) -> void:
|
||||
var buses: Dictionary = spec.get("buses", {})
|
||||
var offset_db: float = buses.get(bus, 0.0)
|
||||
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
|
||||
_save_prefs()
|
||||
|
||||
|
||||
func get_volume(bus: String) -> float:
|
||||
return _bus_volumes.get(bus, 0.0)
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073 stub) ---
|
||||
# --- Volume persistence (user://audio_prefs.cfg) ---
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
return
|
||||
for bus_name in BUSES:
|
||||
if cfg.has_section_key("audio", bus_name):
|
||||
var db: float = cfg.get_value("audio", bus_name, 0.0)
|
||||
# Apply directly: bypass _save_prefs() on initial load.
|
||||
_bus_volumes[bus_name] = db
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx >= 0:
|
||||
AudioServer.set_bus_volume_db(idx, db)
|
||||
|
||||
|
||||
func _save_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
for bus_name in BUSES:
|
||||
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
|
||||
var err := cfg.save(PREFS_PATH)
|
||||
if err != OK:
|
||||
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073) ---
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## Full crossfade implementation deferred to Sprint 9+ (D-073).
|
||||
## Stub exists so server integration can call it without conditional checks.
|
||||
func set_zone(_zone_id: String) -> void:
|
||||
pass
|
||||
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||
## No-op if assets absent (D-038) or same zone.
|
||||
func set_zone(zone_id: String) -> void:
|
||||
if zone_id == _current_zone_id:
|
||||
return
|
||||
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
|
||||
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
|
||||
_current_zone_id = zone_id
|
||||
_kill_zone_tweens()
|
||||
|
||||
# Fade out old ambient layer (if different asset from incoming zone)
|
||||
if not old_asset.is_empty() and old_asset != new_asset:
|
||||
if _ambient_players.has(old_asset):
|
||||
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
|
||||
if is_instance_valid(old_player):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
|
||||
tween.tween_callback(stop_loop.bind(old_asset))
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
# Fade in new ambient layer
|
||||
if not new_asset.is_empty():
|
||||
if _ambient_players.has(new_asset):
|
||||
# Already playing (interrupted reverse crossfade) — tween from current volume
|
||||
var existing: AudioStreamPlayer = _ambient_players[new_asset]
|
||||
if is_instance_valid(existing):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
elif has_asset(new_asset):
|
||||
var new_player := play_loop(new_asset, BUS_AMBIENT)
|
||||
if new_player:
|
||||
new_player.volume_db = -80.0
|
||||
var tween := create_tween()
|
||||
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
|
||||
func _kill_zone_tweens() -> void:
|
||||
for tween in _zone_tweens:
|
||||
if tween != null and tween.is_valid():
|
||||
tween.kill()
|
||||
_zone_tweens.clear()
|
||||
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
@@ -23,7 +23,12 @@ var player_entity_id: int = 1
|
||||
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
||||
|
||||
# v5 fields (#414)
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds} or null
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
||||
|
||||
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
||||
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
||||
# Server sends this field as part of the player's capability snapshot.
|
||||
var lattice_profile: String = "lattice_baseline"
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
@@ -40,9 +45,46 @@ var dialogue_active: bool = false
|
||||
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}]
|
||||
|
||||
# v8 fields (#305, D-028): NPC follow-up after player dialogue choice
|
||||
var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
||||
|
||||
# v9 fields (#535, D-078): Overheard NPC-to-NPC conversations
|
||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
|
||||
# #125, D-018: Close-range sound events for positional 2D audio.
|
||||
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
@@ -64,6 +106,14 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
@@ -100,6 +150,10 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
current_monologue = null
|
||||
|
||||
# #122: lattice_profile — character insert capability level for monologue colour
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
player_stance = snapshot.player_stance
|
||||
@@ -122,6 +176,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
|
||||
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||
conversation_events = snapshot.conversation_events
|
||||
else:
|
||||
conversation_events = []
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
|
||||
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||
conversation_ended = snapshot.conversation_ended
|
||||
else:
|
||||
conversation_ended = []
|
||||
|
||||
# v8: dialogue_response (#305, D-028) — NPC follow-up after player choice
|
||||
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||
dialogue_response = snapshot.dialogue_response
|
||||
else:
|
||||
dialogue_response = null
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
@@ -132,6 +204,49 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
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
|
||||
|
||||
# D-018: Sound events from server — partition by range_category.
|
||||
# #126: Medium → fog-edge directional indicators.
|
||||
# #125: Close → positional 2D audio via AudioManager.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
close_sound_events.append(se)
|
||||
else:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -179,7 +179,7 @@ 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():
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
@@ -192,9 +192,9 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
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.
|
||||
@@ -241,6 +241,17 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
|
||||
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
|
||||
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
|
||||
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
|
||||
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
|
||||
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
|
||||
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
|
||||
if old_conv_events.size() > 0:
|
||||
var new_conv_events: Array = snapshot.get("conversation_events", [])
|
||||
snapshot["conversation_events"] = old_conv_events + new_conv_events
|
||||
var old_conv_ended: Array = _last_snapshot.get("conversation_ended", [])
|
||||
if old_conv_ended.size() > 0:
|
||||
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
||||
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
|
||||
_last_snapshot = snapshot
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
@@ -251,7 +262,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"
|
||||
@@ -396,6 +407,37 @@ func _test_snapshot() -> Dictionary:
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
||||
# Conversation ends after 6 exchanges (~30 ticks).
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif _test_tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": _test_tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
@@ -417,6 +459,8 @@ func _test_snapshot() -> Dictionary:
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
|
||||
@@ -90,6 +90,15 @@ const PERIPHERAL_ALPHA: float = 0.5
|
||||
const FACING_INDICATOR_SIZE: float = 6.0
|
||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
|
||||
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
||||
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
|
||||
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 640
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.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
|
||||
|
||||
+185
-4
@@ -14,14 +14,23 @@ extends Node2D
|
||||
@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 debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _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
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
@@ -53,6 +62,8 @@ func _ready() -> void:
|
||||
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
@@ -81,6 +92,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"):
|
||||
@@ -102,6 +123,9 @@ func _process(_delta: float) -> void:
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
||||
_play_recognition_chimes()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
@@ -110,12 +134,31 @@ func _process(_delta: float) -> void:
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
# D-061: Show dialogue if server sent one this tick (#434)
|
||||
_consume_dialogue()
|
||||
|
||||
# #535: Consume overheard conversation events and responses
|
||||
_consume_conversation_events()
|
||||
_consume_conversation_ended()
|
||||
_consume_dialogue_response()
|
||||
|
||||
# Track camera to player position every frame (D-015: locked, no panning)
|
||||
if _camera_anchored:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
@@ -135,6 +178,8 @@ func _process(_delta: float) -> void:
|
||||
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
|
||||
@@ -142,6 +187,14 @@ func _process(_delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
if settings_dialog.is_open():
|
||||
settings_dialog.close()
|
||||
else:
|
||||
settings_dialog.open()
|
||||
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
|
||||
@@ -164,6 +217,79 @@ 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()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
# Consume-once: events are cleared after processing so they don't replay if
|
||||
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
|
||||
func _play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
# "The chime marks the character's attention shifting" (D-067).
|
||||
# Entities that complete recognition (leave pending_recognitions) are removed
|
||||
# from _known_recognition_ids so they can chime again if re-encountered.
|
||||
func _play_recognition_chimes() -> void:
|
||||
var active_ids: Dictionary = {}
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
active_ids[eid] = true
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
# Expire IDs no longer in the recognition queue
|
||||
for eid in _known_recognition_ids.keys():
|
||||
if not active_ids.has(eid):
|
||||
_known_recognition_ids.erase(eid)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
|
||||
# Only activates when no other dip (dialogue/confrontation) is running.
|
||||
# Only deactivates its own dip — never touches dialogue/confrontation.
|
||||
# D-070: no UI indicator — the boost is "felt, not computed."
|
||||
func _update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
@@ -176,7 +302,12 @@ func _consume_monologue() -> void:
|
||||
return
|
||||
_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))
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
@@ -184,6 +315,7 @@ func _consume_monologue() -> void:
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
@@ -197,6 +329,7 @@ func _consume_dialogue() -> void:
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
@@ -205,6 +338,34 @@ func _consume_dialogue() -> void:
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||
# Each event carries pre-occluded text — render verbatim in the dialogue log.
|
||||
func _consume_conversation_events() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_events:
|
||||
dialogue_box.append_conversation_event(event)
|
||||
GameState.conversation_events = []
|
||||
|
||||
|
||||
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
|
||||
func _consume_conversation_ended() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_ended:
|
||||
dialogue_box.on_conversation_ended(event)
|
||||
GameState.conversation_ended = []
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
@@ -219,9 +380,29 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
|
||||
|
||||
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
|
||||
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
|
||||
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if monologue_display:
|
||||
monologue_display.show_monologue(text, duration)
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_pause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_unpause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-064: Handle walk-away → send WalkAway{npc_id} to server
|
||||
|
||||
@@ -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 = 8
|
||||
const PROTOCOL_VERSION: int = 9
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -180,6 +180,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
||||
}
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
|
||||
# Each event carries pre-occluded text plus speaker/target attribution.
|
||||
var conversation_events: Array = []
|
||||
var raw_conv_events: Variant = raw.get("conversation_events")
|
||||
if raw_conv_events is Array:
|
||||
for raw_ce in raw_conv_events:
|
||||
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
|
||||
conversation_events.append({
|
||||
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||
"target_id": int(raw_ce.get("target_id", 0)),
|
||||
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||
"target_name": str(raw_ce.get("target_name", "")),
|
||||
"occluded_line": str(raw_ce["occluded_line"]),
|
||||
})
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
|
||||
var conversation_ended: Array = []
|
||||
var raw_conv_ended: Variant = raw.get("conversation_ended")
|
||||
if raw_conv_ended is Array:
|
||||
for raw_end in raw_conv_ended:
|
||||
if raw_end is Dictionary:
|
||||
conversation_ended.append({
|
||||
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -195,6 +221,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"current_dialogue": current_dialogue,
|
||||
"dialogue_response": dialogue_response,
|
||||
"pending_recognitions": pending_recognitions,
|
||||
"conversation_events": conversation_events,
|
||||
"conversation_ended": conversation_ended,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,152 @@
|
||||
class_name SoundIndicatorRenderer
|
||||
extends Node2D
|
||||
|
||||
## Medium-range sound indicators (#126, D-018).
|
||||
## Renders directional arrows at the fog boundary for sounds outside LOS.
|
||||
##
|
||||
## Close-range sounds → 2D positional audio (handled by AudioManager).
|
||||
## Medium-range sounds → visual arrow at fog edge pointing toward source.
|
||||
##
|
||||
## Color per D-018/D-069:
|
||||
## Neutral #c8d0e0 — footsteps, generic sounds
|
||||
## Voice #e8c547 — speech, conversation, social sounds
|
||||
## Danger #d45d5d — alert, gunshot, explosion, threat
|
||||
##
|
||||
## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over
|
||||
## the last FADE_DURATION seconds. New events are appended each tick;
|
||||
## expired indicators are removed by _process(). Deduplication prevents
|
||||
## the same source position from stacking multiple arrows.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
# Visual parameters
|
||||
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
|
||||
const FADE_DURATION: float = 0.6 # Fade-out window at end
|
||||
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
|
||||
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
|
||||
const ARROW_LEN: float = 12.0 # Length from tip to base
|
||||
|
||||
# D-018/D-069 colors — sourced from Constants to prevent palette drift
|
||||
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
|
||||
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
|
||||
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
|
||||
|
||||
# Indicators: [{x, y, event_type, elapsed}]
|
||||
var _indicators: Array = []
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
var i := _indicators.size() - 1
|
||||
while i >= 0:
|
||||
_indicators[i].elapsed += delta
|
||||
if _indicators[i].elapsed >= INDICATOR_LIFETIME:
|
||||
_indicators.remove_at(i)
|
||||
i -= 1
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Append new medium-range sound events from snapshot.
|
||||
## events: Array of {x: float, y: float, event_type: String}
|
||||
## Only Medium range_category events should be passed.
|
||||
## Deduplicates by tile position — if an indicator already exists at (x,y),
|
||||
## its timer resets instead of spawning a duplicate arrow.
|
||||
func update_sound_events(events: Array) -> void:
|
||||
for evt in events:
|
||||
if not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
var ex: float = float(evt.x)
|
||||
var ey: float = float(evt.y)
|
||||
# Deduplicate: reset timer if an indicator already exists at this tile
|
||||
var found := false
|
||||
for ind in _indicators:
|
||||
if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey):
|
||||
ind.elapsed = 0.0
|
||||
ind.event_type = evt.get("event_type", "")
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_indicators.append({
|
||||
"x": ex,
|
||||
"y": ey,
|
||||
"event_type": evt.get("event_type", ""),
|
||||
"elapsed": 0.0,
|
||||
})
|
||||
if not _indicators.is_empty():
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
var player_world: Vector2 = GameState.player_position * TILE_SIZE
|
||||
|
||||
# Compute half-extents of the visible world area from camera zoom + viewport.
|
||||
# Arrows are placed at this boundary minus EDGE_INSET so they sit just inside
|
||||
# the fog edge and don't clip to the physical screen border.
|
||||
var vp_size := get_viewport().get_visible_rect().size
|
||||
var cam := get_viewport().get_camera_2d()
|
||||
var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM
|
||||
var half_extents: Vector2 = vp_size / (2.0 * zoom)
|
||||
|
||||
for ind in _indicators:
|
||||
var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE
|
||||
var dir: Vector2 = sound_world - player_world
|
||||
if dir.is_zero_approx():
|
||||
continue
|
||||
dir = dir.normalized()
|
||||
|
||||
# Project direction onto the visible-area rectangle boundary
|
||||
var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET)
|
||||
|
||||
# Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds
|
||||
var t: float = ind.elapsed / INDICATOR_LIFETIME
|
||||
var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME
|
||||
var alpha: float
|
||||
if t < fade_start:
|
||||
alpha = 1.0
|
||||
else:
|
||||
alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME))
|
||||
|
||||
var color: Color = color_for_type(ind.event_type)
|
||||
color.a = alpha * 0.9
|
||||
_draw_arrow(edge_pt, dir, color)
|
||||
|
||||
|
||||
## Project from center along dir to the boundary of a rectangle with
|
||||
## half_extents, inset by inset pixels. Returns the boundary point.
|
||||
func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2:
|
||||
var h := Vector2(
|
||||
maxf(half.x - inset, 8.0),
|
||||
maxf(half.y - inset, 8.0),
|
||||
)
|
||||
# Ray-AABB slab test: find smallest positive t where the ray exits
|
||||
var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x)
|
||||
var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y)
|
||||
var t: float = minf(t_x, t_y)
|
||||
return center + dir * t
|
||||
|
||||
|
||||
## Draw a filled arrowhead at pos pointing in dir.
|
||||
## The tip is at pos; the base is ARROW_LEN pixels behind along -dir.
|
||||
func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var base_center := pos - dir * ARROW_LEN
|
||||
draw_polygon(
|
||||
PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
|
||||
PackedColorArray([color, color, color])
|
||||
)
|
||||
|
||||
|
||||
## Map event type string → D-018 color category.
|
||||
func color_for_type(event_type: String) -> Color:
|
||||
var et := event_type.to_lower()
|
||||
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
|
||||
return COLOR_VOICE
|
||||
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
|
||||
or et.contains("alert") or et.contains("threat"):
|
||||
return COLOR_DANGER
|
||||
return COLOR_NEUTRAL
|
||||
@@ -16,6 +16,7 @@ extends Node2D
|
||||
@onready var tile_renderer = $FogGroup/FloorTiles
|
||||
@onready var fog_renderer = $FogOverlay
|
||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
@@ -42,3 +43,7 @@ func update_from_state() -> void:
|
||||
# Update entity sprites
|
||||
if entity_renderer and entity_renderer.has_method("update_entities"):
|
||||
entity_renderer.update_entities(GameState.visible_entities)
|
||||
|
||||
# D-018 #126: Update medium-range sound indicators
|
||||
if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"):
|
||||
sound_indicator_renderer.update_sound_events(GameState.medium_sound_events)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
const VALUE_COLOR := Color("#c8d0e0")
|
||||
const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
|
||||
var _cached_font: Font = null
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
return
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
left_lines.append(["tick", str(GameState.current_tick)])
|
||||
right_lines.append(["fps", str(Engine.get_frames_per_second())])
|
||||
|
||||
var pos := GameState.player_position
|
||||
left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]])
|
||||
right_lines.append(["facing", GameState.player_facing])
|
||||
|
||||
left_lines.append(["stance", GameState.player_stance])
|
||||
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
|
||||
|
||||
left_lines.append(["entities", str(GameState.visible_entities.size())])
|
||||
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
|
||||
|
||||
left_lines.append(["interactions", str(GameState.nearby_interactions.size())])
|
||||
right_lines.append(["recognitions", str(GameState.pending_recognitions.size())])
|
||||
|
||||
var mono_status := "active" if GameState.current_monologue != null else "idle"
|
||||
var dlg_status := "active" if GameState.dialogue_active else "idle"
|
||||
left_lines.append(["monologue", mono_status])
|
||||
right_lines.append(["dialogue", dlg_status])
|
||||
|
||||
left_lines.append(["stationary", str(GameState.stationary_ticks)])
|
||||
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
|
||||
|
||||
var gt := GameState.game_time
|
||||
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
|
||||
left_lines.append(["time", time_str])
|
||||
right_lines.append(["tick_rate", rate_str])
|
||||
|
||||
var mode_str := "test" if SimBridge.test_mode else "live"
|
||||
var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF"
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
var right_value_w: float = 0.0
|
||||
|
||||
for line in left_lines:
|
||||
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
for line in right_lines:
|
||||
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
|
||||
var header_text := "F3 DEBUG"
|
||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count := max(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
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.
@@ -518,7 +518,7 @@ func test_bug_report_not_double_activatable() -> void:
|
||||
|
||||
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)
|
||||
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("")
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
|
||||
## Spec refs:
|
||||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
|
||||
## D-069: Audio dip profiles — confrontation drops WorldSFX 4–6dB
|
||||
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
|
||||
##
|
||||
## Test layers:
|
||||
## 1. D-068 Bus architecture constants (no implementation required)
|
||||
## 2. D-069 Dip profile spec values (no implementation required)
|
||||
## 3. AudioManager API — dip state machine, signal emission
|
||||
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
|
||||
class_name TestAudioBusRouting
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: D-068 Bus Architecture Constants
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_bus_world_sfx_name() -> void:
|
||||
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||||
|
||||
func test_d068_bus_ambient_name() -> void:
|
||||
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
|
||||
|
||||
func test_d068_bus_player_actions_name() -> void:
|
||||
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
|
||||
|
||||
func test_d068_bus_ui_sounds_name() -> void:
|
||||
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
|
||||
|
||||
func test_d068_bus_music_name() -> void:
|
||||
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
|
||||
|
||||
func test_d068_five_buses_defined() -> void:
|
||||
## D-068: Exactly 5 buses in the architecture.
|
||||
assert_that(AudioManager.BUSES.size()).is_equal(5)
|
||||
|
||||
func test_d068_all_bus_names_present_in_buses_array() -> void:
|
||||
## D-068: BUSES array must contain all 5 named buses.
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
|
||||
|
||||
func test_d068_bus_names_are_unique() -> void:
|
||||
## D-068: No two buses share a name.
|
||||
var seen: Dictionary = {}
|
||||
for bus_name in AudioManager.BUSES:
|
||||
assert_that(seen.has(bus_name)).is_false()
|
||||
seen[bus_name] = true
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-069 Dip Profile Spec Values
|
||||
# Close-range sound events go on WorldSFX; confrontation dips that bus 4–6 dB.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_three_dip_profiles_exist() -> void:
|
||||
## D-069: Three profiles — dialogue, confrontation, listening_focus.
|
||||
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
|
||||
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
|
||||
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
|
||||
|
||||
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
|
||||
## D-069: Confrontation must drop WorldSFX by 4–6 dB. Sprint 12 uses -5.0 dB.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("WorldSFX")).is_true()
|
||||
var dip_db: float = buses["WorldSFX"]
|
||||
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
|
||||
|
||||
func test_d069_confrontation_dips_ambient() -> void:
|
||||
## D-069: Confrontation suppresses both Ambient and WorldSFX.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
|
||||
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
|
||||
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
|
||||
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
|
||||
assert_that(spec.has("filter_hz")).is_true()
|
||||
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
|
||||
|
||||
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
|
||||
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
|
||||
## Confrontation is the one that mutes world SFX.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
assert_that(buses.has("WorldSFX")).is_false()
|
||||
|
||||
func test_d069_listening_focus_boosts_world_sfx() -> void:
|
||||
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||||
assert_that(buses.has("WorldSFX")).is_true()
|
||||
assert_that(buses["WorldSFX"] > 0.0).is_true()
|
||||
|
||||
func test_d069_all_profiles_have_positive_ease_times() -> void:
|
||||
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
|
||||
for profile in AudioManager.DIP_SPECS.keys():
|
||||
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
|
||||
assert_that(spec.has("ease_in")).is_true()
|
||||
assert_that(spec.has("ease_out")).is_true()
|
||||
assert_that(spec["ease_in"] > 0.0).is_true()
|
||||
assert_that(spec["ease_out"] > 0.0).is_true()
|
||||
|
||||
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
|
||||
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
|
||||
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||||
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||||
assert_that(conf_out >= dial_out).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2b: D-067 Recognition Chime
|
||||
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
|
||||
# ==============================================================================
|
||||
|
||||
func test_d067_chime_recognition_constant_exists() -> void:
|
||||
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
|
||||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||||
|
||||
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
|
||||
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
|
||||
## "Neural lattice firing" feel — soft crystalline tone.
|
||||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||||
|
||||
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
|
||||
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
|
||||
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
|
||||
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
|
||||
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
|
||||
## No further assertion needed — play() default bus IS UISounds by design.
|
||||
|
||||
func test_d067_chime_noop_when_asset_absent() -> void:
|
||||
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
|
||||
## play(CHIME_RECOGNITION) must be a no-op (no crash).
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: AudioManager API — Dip State Machine
|
||||
# ==============================================================================
|
||||
|
||||
func test_audio_manager_initial_state_no_active_dip() -> void:
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_sets_active() -> void:
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
|
||||
func test_audio_manager_clear_dip_resets_active() -> void:
|
||||
AudioManager.apply_dip("confrontation")
|
||||
AudioManager.clear_dip()
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
|
||||
## Unknown profile: push_warning + early return, active dip stays empty.
|
||||
AudioManager.apply_dip("not_a_real_profile")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
|
||||
## apply_dip() must emit dip_changed(profile) synchronously.
|
||||
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
|
||||
## so a mutable reference type is required to observe signal argument inside the closure.
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received[0]).is_equal("dialogue")
|
||||
|
||||
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
|
||||
## clear_dip() must emit dip_changed("") to signal audio restored.
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received := ["sentinel"]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received[0]).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||||
## Switching profiles mid-dip: active profile must update to the new one.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.apply_dip("confrontation")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||||
|
||||
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
|
||||
## Switching from dialogue to confrontation emits dip_changed("confrontation").
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("confrontation")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received[0]).is_equal("confrontation")
|
||||
|
||||
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
|
||||
## has_asset() must return false for a key that was never registered.
|
||||
## Ensures no-op fallback path (D-038) is correctly guarded.
|
||||
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: D-018 Snapshot Integration — #125 implemented
|
||||
# GameState partitions sound_events by range_category into close_sound_events
|
||||
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
|
||||
# ==============================================================================
|
||||
|
||||
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
|
||||
## #125: Close-range events must land in GameState.close_sound_events.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
|
||||
|
||||
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
|
||||
## #126/#125: Medium-range events land in medium_sound_events, not close.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(0)
|
||||
|
||||
func test_snapshot_partitions_close_and_medium_events() -> void:
|
||||
## D-018: Mixed batch — Close and Medium events partitioned correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
|
||||
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
|
||||
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
|
||||
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
|
||||
## When snapshot has no sound_events, both close and medium arrays are cleared.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
|
||||
})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(0)
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: AudioManager.play_sound_event — asset registry and routing
|
||||
# ==============================================================================
|
||||
|
||||
func test_audio_manager_sound_event_assets_registry_exists() -> void:
|
||||
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
|
||||
|
||||
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
|
||||
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
|
||||
|
||||
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
|
||||
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
|
||||
|
||||
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
|
||||
## #125 / D-038: Unknown event types must be silently skipped (no-op).
|
||||
## play_sound_event should not crash and should not emit sound.
|
||||
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
|
||||
# No assertion needed — absence of crash is the test.
|
||||
|
||||
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
|
||||
## #125: Empty event type string must be a no-op.
|
||||
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
|
||||
# No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
|
||||
# ==============================================================================
|
||||
|
||||
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
|
||||
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
|
||||
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
|
||||
|
||||
func test_d071_stationary_ticks_resets_on_movement() -> void:
|
||||
## D-071: stationary_ticks must reset to 0 when the player position changes.
|
||||
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
func test_d071_stationary_ticks_reaches_threshold() -> void:
|
||||
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
|
||||
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
for tick in range(31):
|
||||
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks >= 30).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
|
||||
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
AudioManager.apply_dip("confrontation")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||||
|
||||
func test_d069_listening_focus_cleared_by_dialogue() -> void:
|
||||
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
|
||||
func test_d069_clear_dip_noop_when_empty() -> void:
|
||||
## D-069: clear_dip() when no dip active must be a safe no-op.
|
||||
AudioManager.clear_dip() # Should not crash
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
@@ -0,0 +1,634 @@
|
||||
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
|
||||
##
|
||||
## Spec refs:
|
||||
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
|
||||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
|
||||
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
|
||||
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
|
||||
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
|
||||
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
|
||||
##
|
||||
## Tickets covered:
|
||||
## #529 — Zone crossfade implementation (set_zone body + tween timing)
|
||||
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
|
||||
## #531 — Recognition chime fires at cognitive delay onset
|
||||
## #533 — NPC conversation murmur wired to WorldSFX bus
|
||||
##
|
||||
## Test layers:
|
||||
## 1. Zone crossfade spec and API (D-073 / #529)
|
||||
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
|
||||
## 3. Volume slider proportional dip (D-068 / D-069)
|
||||
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
|
||||
## 5. Recognition chime onset verification (D-067 / #531)
|
||||
## 6. NPC murmur routing (D-072 / #533)
|
||||
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
|
||||
class_name TestAudioSprint13
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: Zone Crossfade — D-073 / #529
|
||||
# set_zone() stub exists now; full implementation lands in #529.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d073_set_zone_method_exists() -> void:
|
||||
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
|
||||
assert_that(AudioManager.has_method("set_zone")).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_hub_does_not_crash() -> void:
|
||||
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
|
||||
AudioManager.set_zone("hub")
|
||||
|
||||
|
||||
func test_d073_set_zone_bar_does_not_crash() -> void:
|
||||
## D-073: set_zone("bar") is a safe call.
|
||||
AudioManager.set_zone("bar")
|
||||
|
||||
|
||||
func test_d073_set_zone_corridor_does_not_crash() -> void:
|
||||
## D-073: set_zone("corridor") is a safe call.
|
||||
AudioManager.set_zone("corridor")
|
||||
|
||||
|
||||
func test_d073_set_zone_empty_string_does_not_crash() -> void:
|
||||
## D-073: set_zone("") edge case — no zone ID. Must not crash.
|
||||
AudioManager.set_zone("")
|
||||
|
||||
|
||||
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
|
||||
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
|
||||
AudioManager.set_zone("nonexistent_zone_xyz")
|
||||
|
||||
|
||||
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
|
||||
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
|
||||
## Test verifies naming convention is documentable. Activates once #529 adds the map.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("hub")).is_true()
|
||||
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: bar → amb_bar_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("bar")).is_true()
|
||||
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: smuggling corridor → amb_corridor_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("corridor")).is_true()
|
||||
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
|
||||
|
||||
|
||||
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
|
||||
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
|
||||
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
|
||||
## scan in main.gd per D-020 server-authoritative state.
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("bar")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
|
||||
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
|
||||
## D-073: Crossfade tween duration must be 1.5-2.0s.
|
||||
## Activates once #529 defines the duration constant.
|
||||
if not "CROSSFADE_DURATION" in AudioManager:
|
||||
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
|
||||
return
|
||||
var duration: float = AudioManager.CROSSFADE_DURATION
|
||||
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
|
||||
## D-073: Crossing back to the current zone should not restart a crossfade.
|
||||
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
|
||||
return
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("hub")
|
||||
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
|
||||
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
|
||||
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
|
||||
|
||||
|
||||
func test_d073_rapid_zone_crossing_interruptible() -> void:
|
||||
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
|
||||
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
|
||||
## volume — new tween starts from current position. No stacked tweens, no crash.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
|
||||
return
|
||||
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
|
||||
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
|
||||
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
|
||||
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
|
||||
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
|
||||
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
|
||||
## Zone tweens array should only contain active tweens from the last set_zone call.
|
||||
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_load_prefs_persistence_roundtrip() -> void:
|
||||
## #528: Volume persistence — save/load roundtrip via ConfigFile.
|
||||
## Sets non-default volumes, saves, reloads, and verifies values match.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
|
||||
## _save_prefs() fires inside set_volume() — prefs file is written.
|
||||
## Reload prefs by calling _load_prefs() directly.
|
||||
AudioManager._load_prefs()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
|
||||
## Reset to defaults for subsequent tests.
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
|
||||
|
||||
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
|
||||
## #528: _load_prefs() with a missing/corrupted config file must not crash.
|
||||
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
|
||||
## This test documents the graceful fallback behavior.
|
||||
AudioManager._load_prefs()
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-069 Dip Timing and dB Spec Values
|
||||
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_ease_in_is_300ms() -> void:
|
||||
## D-069: Dialogue dip ease-in = 300ms (0.3s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.3, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ease_out_is_500ms() -> void:
|
||||
## D-069: Dialogue dip ease-out = 500ms (0.5s).
|
||||
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_in_is_500ms() -> void:
|
||||
## D-069: Confrontation dip ease-in = 500ms (0.5s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_out_is_1000ms() -> void:
|
||||
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
|
||||
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(1.0, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
|
||||
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
|
||||
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
|
||||
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
|
||||
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
|
||||
|
||||
|
||||
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
|
||||
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
|
||||
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
|
||||
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
|
||||
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
|
||||
## must NOT appear in the spec — world events remain audible during conversation.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
assert_that(buses.has("WorldSFX")).is_false()
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
assert_that(buses.has("Music")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
|
||||
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
|
||||
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
|
||||
|
||||
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
|
||||
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
|
||||
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_false()
|
||||
|
||||
|
||||
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
|
||||
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
|
||||
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
|
||||
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_filter_hz_is_800hz() -> void:
|
||||
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
|
||||
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
|
||||
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_set_volume_get_volume_roundtrip() -> void:
|
||||
## D-068: set_volume / get_volume roundtrip preserves the slider value.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
|
||||
|
||||
func test_d068_set_volume_persists_across_all_buses() -> void:
|
||||
## D-068: Each of the 5 buses has an independent volume setting.
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
|
||||
## D-069: get_volume() always returns the player slider setting (base).
|
||||
## The effective AudioServer volume during a dip = base + offset_db.
|
||||
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
## Even during dip, get_volume returns the base (not base + dip offset).
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_set_volume_during_active_dip_updates_base() -> void:
|
||||
## D-069: Changing slider mid-dip must update the base so the proportional
|
||||
## calculation uses the new slider value (not the pre-dip value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
|
||||
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
|
||||
## must still reflect the player slider setting (not the dipped value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.clear_dip()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
|
||||
#
|
||||
# These tests verify GameState has the snapshot fields needed for #530 wiring.
|
||||
# The assertions on AudioManager dip activation are stubbed pending implementation.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_game_state_current_dialogue_field_exists() -> void:
|
||||
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
|
||||
## Field must exist so #530 wiring can check it.
|
||||
assert_that("current_dialogue" in GameState).is_true()
|
||||
|
||||
|
||||
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
|
||||
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael", "npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around.", "options": [],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
assert_that(GameState.current_dialogue is Dictionary).is_true()
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
|
||||
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
|
||||
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
|
||||
AudioManager.clear_dip()
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
## Precondition: dialogue IS active in GameState.
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
|
||||
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2}) # dialogue ends
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d071_listening_focus_gate_is_30_ticks() -> void:
|
||||
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
|
||||
## The tick gate is the CALLER's responsibility per AudioManager comment.
|
||||
## This test documents the threshold so it doesn't silently drift.
|
||||
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
|
||||
const LISTENING_FOCUS_TICK_GATE := 30
|
||||
## Stub: verify the spec value is documented.
|
||||
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
|
||||
|
||||
|
||||
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
|
||||
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
|
||||
## Verify AudioManager does not expose a confrontation_ui_shown signal.
|
||||
var signals: Array = AudioManager.get_signal_list().map(
|
||||
func(s: Dictionary) -> String: return s.name
|
||||
)
|
||||
assert_that(signals.has("confrontation_ui_shown")).is_false()
|
||||
assert_that(signals.has("listening_focus_shown")).is_false()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: Recognition Chime Onset — D-067 / #531
|
||||
#
|
||||
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
|
||||
# pending_recognitions), NOT at completion (when it leaves).
|
||||
# ==============================================================================
|
||||
|
||||
func test_d067_chime_recognition_constant_defined() -> void:
|
||||
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
|
||||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||||
|
||||
|
||||
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
|
||||
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
|
||||
## Key must match filename stem in res://audio/.
|
||||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||||
|
||||
|
||||
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
|
||||
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
|
||||
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
|
||||
## Verify play() default bus is UISounds (the chime caller uses the default).
|
||||
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
|
||||
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
|
||||
## Client must not crash when audio branch has not yet provided the .ogg file.
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
## No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
|
||||
## D-067: "Onset" of cognitive delay = first frame an entity appears in
|
||||
## pending_recognitions, at remaining_ticks == total_delay_ticks.
|
||||
## This is the moment the chime must fire.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
var rec: Dictionary = GameState.pending_recognitions[0]
|
||||
## Onset condition: remaining == total (delay just started)
|
||||
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
|
||||
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
|
||||
## The chime must NOT fire at this point — it fired at onset.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
## Completion: entity removed from array
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
## Chime state: AudioManager must not have a dip triggered by recognition.
|
||||
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
|
||||
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
|
||||
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
|
||||
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
|
||||
## This test verifies FogEntities correctly identifies the onset condition.
|
||||
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
|
||||
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6}
|
||||
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 3, "total_delay_ticks": 6}
|
||||
## Frame 1: entity APPEARS (onset — chime should fire here)
|
||||
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks
|
||||
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
|
||||
## Frame 2: entity mid-progress (chime must NOT re-fire)
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
|
||||
## Frame 3: entity completes (chime must NOT fire)
|
||||
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
|
||||
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
|
||||
## This test documents the spec range so asset authoring can be validated.
|
||||
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
|
||||
## return a value in this range.
|
||||
const CHIME_MIN_DURATION := 0.3
|
||||
const CHIME_MAX_DURATION := 0.4
|
||||
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
|
||||
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
|
||||
return
|
||||
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
|
||||
if stream == null:
|
||||
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
|
||||
return
|
||||
assert_that(stream.get_length() >= CHIME_MIN_DURATION
|
||||
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: NPC Murmur Routing — D-072 / #533
|
||||
# Single universal asset, WorldSFX bus, no zone-specific variants.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
|
||||
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
|
||||
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
|
||||
## by that bus's noise floor relative to the murmur volume.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||||
|
||||
|
||||
func test_d072_play_at_method_accepts_bus_parameter() -> void:
|
||||
## D-072 / #533: play_at() must accept an optional bus string parameter.
|
||||
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
|
||||
assert_that(AudioManager.has_method("play_at")).is_true()
|
||||
|
||||
|
||||
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
|
||||
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
|
||||
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_play_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
|
||||
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_no_zone_specific_murmur_variants() -> void:
|
||||
## D-072: SINGLE universal murmur asset — no zone-specific variants.
|
||||
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
|
||||
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
|
||||
|
||||
|
||||
func test_d072_murmur_does_not_use_ambient_bus() -> void:
|
||||
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
|
||||
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
|
||||
## Verify bus constant distinction.
|
||||
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_registry_size_is_non_negative() -> void:
|
||||
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
|
||||
assert_that(AudioManager.get_registry_size() >= 0).is_true()
|
||||
|
||||
|
||||
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
|
||||
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
|
||||
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
|
||||
assert_that(result == null).is_true()
|
||||
|
||||
|
||||
func test_d068_stop_loop_noop_for_unknown_key() -> void:
|
||||
## D-068: stop_loop() on a key never started — no crash, no error.
|
||||
AudioManager.stop_loop("nonexistent_key_xyzabc")
|
||||
|
||||
|
||||
func test_d068_stop_all_loops_when_none_playing() -> void:
|
||||
## D-068: stop_all_loops() with no active ambient players — no crash.
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
|
||||
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
|
||||
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
|
||||
|
||||
|
||||
func test_d068_play_noop_does_not_change_dip_state() -> void:
|
||||
## D-068: play() on a missing asset must not modify the dip state machine.
|
||||
## Verifies no-op fallback has zero side effects.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.play("nonexistent_asset_xyzabc")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -129,13 +129,13 @@ func test_gauntlet_mode_transitions_off() -> void:
|
||||
|
||||
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)
|
||||
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)
|
||||
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.TELEPORT_HUB)
|
||||
assert_that(wire_name.is_empty()).is_false()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
## #522: OQ-07 resolution — no-insert interaction behavior.
|
||||
## Tests that when insert_active == false, interaction labels disappear
|
||||
## (diegetic test from D-056 and D-057), and that cursor and interaction
|
||||
## list agree on insert state.
|
||||
##
|
||||
## D-056: "Interaction labels render on z-layer 6 (insert overlay).
|
||||
## If the insert is off, labels disappear."
|
||||
## D-057: "Labels render on z-layer 6. If insert is off, labels disappear."
|
||||
##
|
||||
## OQ-07 resolution (expected option a): cursor shape still changes
|
||||
## (character physically orients), but verb labels are suppressed.
|
||||
##
|
||||
## Spec refs: D-056, D-057, D-048, D-049
|
||||
## Ticket #522
|
||||
class_name TestInsertOffBehavior
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
# Reset GameState fields mutated by tests to prevent cross-test leakage.
|
||||
GameState.nearby_interactions = []
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_cursor() -> Node:
|
||||
for path in ["res://ui/cursor_state_machine.tscn", "res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
var scene = load(path)
|
||||
var cursor = scene.instantiate()
|
||||
add_child(cursor)
|
||||
return cursor
|
||||
return null
|
||||
|
||||
|
||||
func _make_cursor_or_skip() -> Node:
|
||||
var cursor = _make_cursor()
|
||||
if cursor == null:
|
||||
push_warning("TestInsertOffBehavior: cursor scene not found — test skipped (awaiting #522)")
|
||||
return cursor
|
||||
|
||||
|
||||
func _make_interaction_list() -> Node:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
var scene = load(path)
|
||||
var node = scene.instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
return null
|
||||
|
||||
|
||||
func _make_list_or_skip() -> Node:
|
||||
var list = _make_interaction_list()
|
||||
if list == null:
|
||||
push_warning("TestInsertOffBehavior: interaction list scene not found — test skipped (awaiting #522)")
|
||||
return list
|
||||
|
||||
|
||||
func _set_test_interactions() -> void:
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
],
|
||||
}]
|
||||
|
||||
|
||||
func _clear_test_interactions() -> void:
|
||||
GameState.nearby_interactions = []
|
||||
|
||||
|
||||
# -- Interaction list: diegetic test (D-056, D-057) ---------------------------
|
||||
|
||||
func test_insert_off_hides_interaction_list() -> void:
|
||||
# D-056/D-057 diegetic test: "If the insert is off, labels disappear."
|
||||
var list = _make_list_or_skip()
|
||||
if list == null:
|
||||
return
|
||||
_set_test_interactions()
|
||||
if list.has_method("set_insert_active"):
|
||||
list.set_insert_active(false)
|
||||
if list.has_method("update_from_state"):
|
||||
list.update_from_state()
|
||||
if list.has_method("is_showing"):
|
||||
assert_that(list.is_showing()).override_failure_message(
|
||||
"D-056/D-057 diegetic test: interaction list must be hidden when insert is off"
|
||||
).is_false()
|
||||
elif list.has_method("get_visible_verb_count"):
|
||||
assert_that(list.get_visible_verb_count()).override_failure_message(
|
||||
"Interaction list must show 0 verbs when insert is off"
|
||||
).is_equal(0)
|
||||
list.queue_free()
|
||||
_clear_test_interactions()
|
||||
|
||||
|
||||
func test_insert_on_shows_interaction_list() -> void:
|
||||
# When insert is on (normal state), verbs should be visible.
|
||||
var list = _make_list_or_skip()
|
||||
if list == null:
|
||||
return
|
||||
_set_test_interactions()
|
||||
if list.has_method("set_insert_active"):
|
||||
list.set_insert_active(true)
|
||||
if list.has_method("update_from_state"):
|
||||
list.update_from_state()
|
||||
if list.has_method("is_showing"):
|
||||
assert_that(list.is_showing()).override_failure_message(
|
||||
"Interaction list must be visible when insert is on and verbs present"
|
||||
).is_true()
|
||||
list.queue_free()
|
||||
_clear_test_interactions()
|
||||
|
||||
|
||||
func test_insert_reenable_restores_list() -> void:
|
||||
# Toggling insert off then on restores verb list visibility.
|
||||
var list = _make_list_or_skip()
|
||||
if list == null:
|
||||
return
|
||||
_set_test_interactions()
|
||||
if not list.has_method("set_insert_active"):
|
||||
list.queue_free()
|
||||
_clear_test_interactions()
|
||||
return
|
||||
list.set_insert_active(false)
|
||||
if list.has_method("update_from_state"):
|
||||
list.update_from_state()
|
||||
list.set_insert_active(true)
|
||||
if list.has_method("update_from_state"):
|
||||
list.update_from_state()
|
||||
if list.has_method("is_showing"):
|
||||
assert_that(list.is_showing()).override_failure_message(
|
||||
"Re-enabling insert must restore list visibility"
|
||||
).is_true()
|
||||
list.queue_free()
|
||||
_clear_test_interactions()
|
||||
|
||||
|
||||
# -- Cursor: insert-off behavior (OQ-07 resolution option a) ------------------
|
||||
|
||||
func test_insert_off_suppresses_should_show_interactions() -> void:
|
||||
# OQ-07 option (a): labels are suppressed when insert is off.
|
||||
# Regardless of cursor visual state, should_show_interactions() must return false.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active"):
|
||||
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
|
||||
cursor.queue_free()
|
||||
return
|
||||
cursor.set_insert_active(false)
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
assert_that(cursor.should_show_interactions()).override_failure_message(
|
||||
"should_show_interactions() must return false when insert is off"
|
||||
).is_false()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
func test_insert_on_restores_should_show_interactions() -> void:
|
||||
# After re-enabling insert, interactions should show again (not in weapon mode).
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active"):
|
||||
push_warning("TestInsertOffBehavior: cursor.set_insert_active not found — awaiting #522")
|
||||
cursor.queue_free()
|
||||
return
|
||||
cursor.set_insert_active(false)
|
||||
cursor.set_insert_active(true)
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
assert_that(cursor.should_show_interactions()).override_failure_message(
|
||||
"should_show_interactions() must return true after re-enabling insert"
|
||||
).is_true()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
func test_insert_off_at_startup_no_corruption() -> void:
|
||||
# Insert can be off from the start — no state machine corruption.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active") or not cursor.has_method("get_state"):
|
||||
cursor.queue_free()
|
||||
return
|
||||
cursor.set_insert_active(false)
|
||||
# Cursor must be in a valid state (not null/error)
|
||||
var state_str := str(cursor.get_state())
|
||||
assert_that(["Default", "EntityHover", "ObjectHover", "WeaponAim"].has(state_str)).override_failure_message(
|
||||
"Cursor must be in a valid state after insert-off at startup, got: %s" % state_str
|
||||
).is_true()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
# -- OQ-07 option (a): cursor shape still changes, labels suppressed ----------
|
||||
|
||||
func test_insert_off_option_a_cursor_still_transitions() -> void:
|
||||
# Option (a): cursor shape changes even when insert off
|
||||
# (character physically orients to target, just no labels).
|
||||
# If implementation chose option (b) instead, this test would fail — that's informative.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_hover_target"):
|
||||
push_warning("TestInsertOffBehavior: set_insert_active or set_hover_target not found — awaiting #522")
|
||||
cursor.queue_free()
|
||||
return
|
||||
cursor.set_insert_active(false)
|
||||
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": true})
|
||||
# Option (a): cursor SHOULD be in EntityHover despite insert being off
|
||||
# Option (b): cursor would stay in Default
|
||||
# Test documents expected behavior — fail message explains which option is active
|
||||
if cursor.has_method("get_state"):
|
||||
var state := str(cursor.get_state())
|
||||
# If this assertion fails, option (b) was implemented (cursor full-suppressed).
|
||||
# Neither (a) nor (b) is wrong — this test documents which was chosen.
|
||||
# Consult decisions/perception.md for the OQ-07 amendment.
|
||||
assert_that(state).override_failure_message(
|
||||
"OQ-07 option (a): cursor should still transition to EntityHover when insert is off. "
|
||||
+ "If this fails, option (b) was implemented (full suppression) — update this test to assert 'Default' instead."
|
||||
).is_equal("EntityHover")
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
# -- Edge cases ---------------------------------------------------------------
|
||||
|
||||
func test_insert_off_plus_weapon_mode_stays_suppressed() -> void:
|
||||
# Insert-off AND weapon mode: should_show_interactions() must still return false.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_weapon_mode"):
|
||||
cursor.queue_free()
|
||||
return
|
||||
cursor.set_insert_active(false)
|
||||
cursor.set_weapon_mode(true)
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
assert_that(cursor.should_show_interactions()).override_failure_message(
|
||||
"Insert-off + weapon mode must keep should_show_interactions() false"
|
||||
).is_false()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
func test_insert_off_shift_override_still_suppressed() -> void:
|
||||
# OQ-07: Insert-off trumps Shift override.
|
||||
# Shift restores interactions in weapon mode, but insert-off is a harder constraint.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active") or not cursor.has_method("set_shift_held"):
|
||||
cursor.queue_free()
|
||||
return
|
||||
cursor.set_insert_active(false)
|
||||
if cursor.has_method("set_shift_held"):
|
||||
cursor.set_shift_held(true)
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
assert_that(cursor.should_show_interactions()).override_failure_message(
|
||||
"Insert-off must suppress interactions even with Shift held (insert-off > shift override)"
|
||||
).is_false()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
func test_insert_off_rapid_toggle_no_corruption() -> void:
|
||||
# Rapid on/off toggle must not leave either system in inconsistent state.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if not cursor.has_method("set_insert_active"):
|
||||
cursor.queue_free()
|
||||
return
|
||||
for _i in range(10):
|
||||
cursor.set_insert_active(false)
|
||||
cursor.set_insert_active(true)
|
||||
# After 10 toggles, insert should be on
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
assert_that(cursor.should_show_interactions()).override_failure_message(
|
||||
"After rapid toggle ending on 'on', interactions must be visible"
|
||||
).is_true()
|
||||
cursor.queue_free()
|
||||
|
||||
|
||||
# -- Cross-system consistency (cursor + list agree) ---------------------------
|
||||
|
||||
func test_cursor_and_list_agree_on_insert_off() -> void:
|
||||
# Both cursor and interaction list must be suppressed when insert is off.
|
||||
# This tests the integration contract — both must read from the same source of truth.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
var list = _make_list_or_skip()
|
||||
|
||||
if cursor == null or list == null:
|
||||
if cursor != null: cursor.queue_free()
|
||||
if list != null: list.queue_free()
|
||||
return
|
||||
|
||||
_set_test_interactions()
|
||||
|
||||
if cursor.has_method("set_insert_active") and list.has_method("set_insert_active"):
|
||||
cursor.set_insert_active(false)
|
||||
list.set_insert_active(false)
|
||||
if list.has_method("update_from_state"):
|
||||
list.update_from_state()
|
||||
|
||||
var cursor_suppressed := true
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
cursor_suppressed = not cursor.should_show_interactions()
|
||||
|
||||
var list_suppressed := true
|
||||
if list.has_method("is_showing"):
|
||||
list_suppressed = not list.is_showing()
|
||||
elif list.has_method("get_visible_verb_count"):
|
||||
list_suppressed = list.get_visible_verb_count() == 0
|
||||
|
||||
assert_that(cursor_suppressed).override_failure_message(
|
||||
"Cursor: should_show_interactions() must return false when insert is off"
|
||||
).is_true()
|
||||
assert_that(list_suppressed).override_failure_message(
|
||||
"Interaction list: must be hidden when insert is off"
|
||||
).is_true()
|
||||
|
||||
cursor.queue_free()
|
||||
list.queue_free()
|
||||
_clear_test_interactions()
|
||||
|
||||
|
||||
func test_game_state_insert_active_field_exists() -> void:
|
||||
# GameState must have an insert_active field after #522 implementation.
|
||||
assert_that(GameState.get("insert_active") != null or "insert_active" in GameState).override_failure_message(
|
||||
"GameState must have insert_active field after #522 — awaiting implementation"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_insert_active_default_true() -> void:
|
||||
# Default state: insert is on (player starts with a functioning neural insert).
|
||||
if not ("insert_active" in GameState):
|
||||
push_warning("TestInsertOffBehavior: GameState.insert_active not found — awaiting #522")
|
||||
return
|
||||
assert_that(GameState.insert_active).override_failure_message(
|
||||
"GameState.insert_active should default to true (insert is normally on)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- D-049: z-layer verification (labels on layer 6) -------------------------
|
||||
|
||||
func test_interaction_list_on_insert_layer() -> void:
|
||||
# D-056/D-057: "Labels render on z-layer 6 (insert overlay)"
|
||||
# This is already tested in test_interaction_list.gd but we verify here
|
||||
# that the z-layer is CANVAS_INSERT (the insert overlay layer).
|
||||
var list = _make_list_or_skip()
|
||||
if list == null:
|
||||
return
|
||||
if list.has_method("get_z_layer"):
|
||||
assert_that(list.get_z_layer()).override_failure_message(
|
||||
"Interaction list labels must render on CANVAS_INSERT (z-layer 6 per D-049)"
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
list.queue_free()
|
||||
|
||||
|
||||
# -- Regression: existing behavior unchanged ----------------------------------
|
||||
|
||||
func test_sprint_suppression_still_works_with_insert_on() -> void:
|
||||
# D-055 regression: Sprint suppresses interaction list regardless of insert state.
|
||||
var list = _make_list_or_skip()
|
||||
if list == null:
|
||||
return
|
||||
_set_test_interactions()
|
||||
# Ensure insert is on (should not affect sprint suppression)
|
||||
if list.has_method("set_insert_active"):
|
||||
list.set_insert_active(true)
|
||||
GameState.player_stance = "Sprint"
|
||||
if list.has_method("update_from_state"):
|
||||
list.update_from_state()
|
||||
if list.has_method("is_showing"):
|
||||
assert_that(list.is_showing()).override_failure_message(
|
||||
"Sprint must suppress interaction list even when insert is on (D-055 regression)"
|
||||
).is_false()
|
||||
elif list.has_method("get_visible_verb_count"):
|
||||
assert_that(list.get_visible_verb_count()).is_equal(0)
|
||||
list.queue_free()
|
||||
_clear_test_interactions()
|
||||
GameState.player_stance = "Walk"
|
||||
|
||||
|
||||
func test_weapon_mode_suppression_still_works_with_insert_on() -> void:
|
||||
# D-056 regression: weapon mode suppresses interactions when insert is on.
|
||||
var cursor = _make_cursor_or_skip()
|
||||
if cursor == null:
|
||||
return
|
||||
if cursor.has_method("set_insert_active"):
|
||||
cursor.set_insert_active(true) # Insert is on
|
||||
if cursor.has_method("set_weapon_mode"):
|
||||
cursor.set_weapon_mode(true)
|
||||
if cursor.has_method("should_show_interactions"):
|
||||
assert_that(cursor.should_show_interactions()).override_failure_message(
|
||||
"Weapon mode must suppress interactions when insert is on (D-056 regression)"
|
||||
).is_false()
|
||||
cursor.queue_free()
|
||||
@@ -268,12 +268,12 @@ func test_insert_off_hides_interaction_list() -> void:
|
||||
|
||||
func test_stance_up_wire_mapping() -> void:
|
||||
# Verify InputMapper.Action.TOGGLE_STANCE_UP maps to "ToggleStanceUp" wire name
|
||||
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
|
||||
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
|
||||
assert_that(wire).is_equal("ToggleStanceUp")
|
||||
|
||||
|
||||
func test_stance_down_wire_mapping() -> void:
|
||||
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
|
||||
var wire = SimBridge.action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
|
||||
assert_that(wire).is_equal("ToggleStanceDown")
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ func test_all_movement_actions_have_wire_mapping() -> void:
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN,
|
||||
]
|
||||
for action in actions_with_mapping:
|
||||
var wire = SimBridge._action_enum_to_wire(action)
|
||||
var wire = SimBridge.action_enum_to_wire(action)
|
||||
assert_that(wire.length()).is_greater(0)
|
||||
|
||||
|
||||
|
||||
@@ -163,23 +163,23 @@ func test_action_enum_to_wire_all_directions_clockwise() -> void:
|
||||
]
|
||||
|
||||
for pair in expected:
|
||||
var wire_name := SimBridge._action_enum_to_wire(pair[0])
|
||||
var wire_name := SimBridge.action_enum_to_wire(pair[0])
|
||||
assert_that(wire_name).is_equal(pair[1])
|
||||
|
||||
|
||||
func test_action_enum_to_wire_non_movement() -> void:
|
||||
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
|
||||
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
|
||||
assert_that(SimBridge._action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
|
||||
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.INTERACT)).is_equal("Interact")
|
||||
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.USE_PERCEPTION_MODE)).is_equal("UsePerceptionMode")
|
||||
assert_that(SimBridge.action_enum_to_wire(InputMapper.Action.PAUSE)).is_equal("Pause")
|
||||
|
||||
|
||||
func test_action_enum_to_wire_open_menu_returns_empty() -> void:
|
||||
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.OPEN_MENU)
|
||||
var wire_name := SimBridge.action_enum_to_wire(InputMapper.Action.OPEN_MENU)
|
||||
assert_that(wire_name).is_equal("")
|
||||
|
||||
|
||||
func test_action_enum_to_wire_unknown_returns_empty() -> void:
|
||||
var wire_name := SimBridge._action_enum_to_wire(9999)
|
||||
var wire_name := SimBridge.action_enum_to_wire(9999)
|
||||
assert_that(wire_name).is_equal("")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
## #122: Monologue display — client tests (Sprint 14)
|
||||
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
|
||||
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
|
||||
##
|
||||
## API per Tyre architecture review:
|
||||
## show_monologue(text, duration, priority=2, is_urgent=false)
|
||||
## GameState.lattice_profile selects colour palette
|
||||
class_name TestMonologueDisplay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _make_display() -> Node:
|
||||
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
|
||||
push_warning("TestMonologueDisplay: scene not found — tests skipped")
|
||||
return null
|
||||
var node = load("res://ui/monologue_display.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _label_text(d: Node) -> String:
|
||||
if d._visible.is_empty():
|
||||
return ""
|
||||
var slot_node: Node = d._visible[0].node
|
||||
return (slot_node.get_child(0) as RichTextLabel).text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle — ensure clean GameState between every test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
||||
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
|
||||
## current_monologue: GameState integration tests need null as start state.
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_nothing_visible_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_stagger_timer_zero_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_show_monologue_with_empty_text_does_not_set_displaying() -> void:
|
||||
# Empty text must be ignored — no visible slot created, no queue entry.
|
||||
# Prevents ghost display nodes and stagger timer contamination.
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_empty_text_does_not_advance_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("", 5.0)
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_empty_text_when_slots_full_does_not_enqueue() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("", 5.0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-line display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_single_line_goes_to_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("One.", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(1)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_show_monologue_sets_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
var before := float(Time.get_ticks_msec())
|
||||
d.show_monologue("Stagger.", 5.0)
|
||||
assert_float(d._next_fade_in_msec).is_greater(before)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAX_VISIBLE = 3 simultaneous lines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_three_lines_all_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
assert_int(d._visible.size()).is_equal(3)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_fourth_line_queues_when_slots_full() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("D", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(3)
|
||||
assert_int(d._queue.size()).is_equal(1)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-overwrite contract (P0, #477)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_line_does_not_replace_first_visible_line() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("First line.", 10.0)
|
||||
var first_text := _label_text(d)
|
||||
# Force stagger active; second call must queue, not display
|
||||
d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0
|
||||
d.show_monologue("Second line.", 5.0)
|
||||
assert_that(_label_text(d)).is_equal(first_text)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_two_visible_lines_coexist_without_overwriting() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0)
|
||||
assert_int(d._visible.size()).is_equal(2)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Priority queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_queue_sorted_highest_priority_first() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("low", 5.0, 1)
|
||||
d.show_monologue("high", 5.0, 4)
|
||||
d.show_monologue("normal", 5.0, 2)
|
||||
assert_int(d._queue[0].priority).is_equal(4)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_drop_replaces_lowest_when_full() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
for i in range(d.MAX_QUEUE):
|
||||
d.show_monologue("low_%d" % i, 5.0, 1)
|
||||
d.show_monologue("critical!", 5.0, 9)
|
||||
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
|
||||
var has_critical := false
|
||||
for e in d._queue:
|
||||
if e.priority == 9:
|
||||
has_critical = true
|
||||
assert_that(has_critical).is_true()
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_ignores_lower_priority_when_full() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
for i in range(d.MAX_QUEUE):
|
||||
d.show_monologue("hi_%d" % i, 5.0, 5)
|
||||
d.show_monologue("noise", 1.0, 1)
|
||||
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
|
||||
for e in d._queue:
|
||||
assert_int(e.priority).is_equal(5)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_never_exceeds_max_depth() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
for i in range(d.MAX_QUEUE + 20):
|
||||
d.show_monologue("flood_%d" % i, 1.0, 2)
|
||||
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expire and drain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_expired_slot_removed_from_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Expires.", 1.0)
|
||||
d._visible[0].expire_timer = -0.1
|
||||
d._process(0.0)
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_drains_when_slot_opens() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("Queued.", 5.0)
|
||||
d._visible[0].expire_timer = -0.1
|
||||
d._next_fade_in_msec = 0.0 # stagger elapsed
|
||||
d._process(0.0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
assert_int(d._visible.size()).is_equal(3)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stagger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_second_call_within_stagger_period_queues() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("First.", 5.0)
|
||||
# _next_fade_in_msec is now ~150ms in the future
|
||||
d.show_monologue("Second.", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(1)
|
||||
assert_int(d._queue.size()).is_equal(1)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_stagger_elapsed_allows_second_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("First.", 5.0)
|
||||
d._next_fade_in_msec = 0.0 # manually expire stagger
|
||||
d.show_monologue("Second.", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(2)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_text_wrapped_in_italic_bbcode() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Italic line.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[i]")
|
||||
assert_that(txt).contains("[/i]")
|
||||
assert_that(txt).contains("Italic line.")
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_text_has_color_bbcode() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Coloured.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#")
|
||||
assert_that(txt).contains("[/color]")
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_augmented"
|
||||
d.show_monologue("Detective.", 5.0)
|
||||
var aug_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Smuggler.", 5.0)
|
||||
var base_txt := _label_text(d)
|
||||
|
||||
assert_that(aug_txt).is_not_equal(base_txt)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_urgent_colour_differs_from_standard() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Normal.", 5.0, 2, false)
|
||||
var std_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
d.show_monologue("Urgent!", 5.0, 3, true)
|
||||
var urg_txt := _label_text(d)
|
||||
|
||||
assert_that(std_txt).is_not_equal(urg_txt)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_unknown_profile_falls_back_without_crash() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
GameState.lattice_profile = "lattice_hypothetical_tier_x"
|
||||
d.show_monologue("Future proof.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_visible_slot_has_tween() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Has tween.", 5.0)
|
||||
assert_that(d._visible[0].tween).is_not_null()
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_visible_slot_stores_priority() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Priority 7.", 5.0, 7)
|
||||
assert_int(d._visible[0].priority).is_equal(7)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_expire_timer_decrements_in_process() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Timer.", 10.0)
|
||||
d._process(1.5)
|
||||
assert_float(d._visible[0].expire_timer).is_less(10.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState integration — current_monologue field (v5, #414)
|
||||
# These tests do not instantiate the scene — they verify apply_snapshot()
|
||||
# correctly populates and clears current_monologue so the display layer
|
||||
# receives valid data (or null) every tick.
|
||||
#
|
||||
# Joint test plan (sprint-14/joint.md §Test Plan Alignment):
|
||||
# "Verify current_monologue: None produces no display (no ghost text from previous tick)"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_monologue_null_when_snapshot_omits_field() -> void:
|
||||
## D-016: Snapshot without current_monologue must clear the field.
|
||||
## Prevents a stale line from a previous tick persisting as ghost text.
|
||||
GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0}
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_monologue_set_from_snapshot() -> void:
|
||||
## apply_snapshot populates current_monologue when the field is a dict.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("A thought.")
|
||||
|
||||
|
||||
func test_gamestate_monologue_null_when_field_is_non_dict() -> void:
|
||||
## Defensive: a non-dictionary value from the server must be rejected (null).
|
||||
GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void:
|
||||
## duration_seconds feeds show_monologue()'s duration param — must not be lost.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5},
|
||||
})
|
||||
var dur: float = float(GameState.current_monologue.get("duration_seconds"))
|
||||
assert_that(dur).is_equal_approx(7.5, 0.001)
|
||||
|
||||
|
||||
func test_gamestate_monologue_id_survives_round_trip() -> void:
|
||||
## The id field is used by SimBridge carry-forward deduplication (#477).
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001")
|
||||
|
||||
|
||||
func test_gamestate_monologue_replaced_by_next_snapshot() -> void:
|
||||
## Each snapshot with a monologue replaces the previous value — no accumulation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0},
|
||||
})
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue.get("id")).is_equal("second")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-049 Z-layer / canvas scope compliance
|
||||
# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20).
|
||||
# Two tests: (1) constant sanity, (2) scene tree structural verification.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_ui_constant_is_20() -> void:
|
||||
## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display.
|
||||
## Sanity check — constant must not drift from the spec.
|
||||
assert_that(Constants.CANVAS_UI).is_equal(20)
|
||||
|
||||
|
||||
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
|
||||
## D-049: Structural verification — MonologueDisplay must be a direct child of
|
||||
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
|
||||
## Catches regressions where the node gets accidentally moved to InsertOverlay
|
||||
## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack.
|
||||
##
|
||||
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay")
|
||||
assert_that(mono != null).is_true()
|
||||
|
||||
var parent: Node = mono.get_parent()
|
||||
assert_that(parent is CanvasLayer).is_true()
|
||||
assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI)
|
||||
@@ -5,6 +5,7 @@ extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
|
||||
var SoundIndicatorScript = load("res://scripts/rendering/sound_indicator_renderer.gd")
|
||||
|
||||
# -- Test data matching Protocol decoded format --
|
||||
|
||||
@@ -230,6 +231,19 @@ func test_entity_renderer_skips_missing_entity_id() -> void:
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
# Regression test for #345: protocol uses entity_id (not id).
|
||||
# An entity dict keyed with "id" (old/wrong format) must be silently dropped.
|
||||
func test_entity_renderer_rejects_old_id_field_format() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
# Old (wrong) format: "id" instead of "entity_id"
|
||||
renderer.update_entities([{"id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
# Correct format: "entity_id" — should be accepted
|
||||
renderer.update_entities([{"entity_id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(99)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_player_color_differs_from_npc() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
@@ -334,6 +348,191 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- EntityRenderer: regression #345 (entity_id field name bug) --
|
||||
# Bug: entity_renderer.gd checked entity_data.has("id") instead of
|
||||
# entity_data.has("entity_id"), causing all entities to be silently dropped.
|
||||
# Fix: use entity_id (Protocol v2 canonical field name).
|
||||
|
||||
func test_regression_345_entity_id_field_renders() -> void:
|
||||
# Regression: entity with correct "entity_id" field MUST be rendered.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(99)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_old_id_field_not_rendered() -> void:
|
||||
# Negative regression: entity using OLD field name "id" (not "entity_id")
|
||||
# must be silently dropped. Pre-fix code accepted "id"; this verifies the fix.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_mixed_batch_only_entity_id_renders() -> void:
|
||||
# Mixed batch: one entity with correct "entity_id", one with old "id" only.
|
||||
# Only the entity_id entity should appear — no cross-contamination.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 1, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"id": 2, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(1)).is_true()
|
||||
assert_that(renderer.entity_nodes.has(2)).is_false()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_entity_id_node_keyed_by_id_value() -> void:
|
||||
# Regression: entity_nodes dict must be keyed by the entity_id VALUE,
|
||||
# not by a string "entity_id" or by the old "id" value.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 42, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.has(42)).is_true()
|
||||
assert_that(renderer.entity_nodes.has("entity_id")).is_false()
|
||||
assert_that(renderer.entity_nodes.has(0)).is_false()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
|
||||
# Regression: entity rendered via entity_id must have correct pixel position.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
var node = renderer.entity_nodes[5]
|
||||
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
|
||||
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
|
||||
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- SoundIndicatorRenderer: #126 D-018 medium-range fog-edge indicators --
|
||||
|
||||
func _make_sound_indicator_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(SoundIndicatorScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
func test_sound_indicator_accepts_medium_events() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([
|
||||
{"x": 10.0, "y": 10.0, "event_type": "Footstep"},
|
||||
{"x": 15.0, "y": 15.0, "event_type": "Voice"},
|
||||
])
|
||||
assert_that(renderer._indicators.size()).is_equal(2)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_drops_events_without_position() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([
|
||||
{"event_type": "Footstep"}, # missing x, y
|
||||
{"x": 5.0, "event_type": "Voice"}, # missing y
|
||||
{"x": 8.0, "y": 3.0, "event_type": "Footstep"}, # valid
|
||||
])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_appends_new_events() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([
|
||||
{"x": 1.0, "y": 1.0, "event_type": "Voice"},
|
||||
{"x": 2.0, "y": 2.0, "event_type": "Footstep"},
|
||||
])
|
||||
assert_that(renderer._indicators.size()).is_equal(2)
|
||||
# New update appends — existing indicators persist until they expire
|
||||
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}])
|
||||
assert_that(renderer._indicators.size()).is_equal(3)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_empty_update_preserves_existing() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 3.0, "y": 3.0, "event_type": "Voice"}])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
# Empty update does not clear existing indicators — they expire via _process
|
||||
renderer.update_sound_events([])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_color_voice() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
assert_that(renderer.color_for_type("voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
|
||||
assert_that(renderer.color_for_type("Voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
|
||||
assert_that(renderer.color_for_type("speech")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_color_danger() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
assert_that(renderer.color_for_type("Gunshot")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
|
||||
assert_that(renderer.color_for_type("alert")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
|
||||
assert_that(renderer.color_for_type("danger")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_color_neutral_for_unknown() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
assert_that(renderer.color_for_type("Footstep")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
|
||||
assert_that(renderer.color_for_type("")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
|
||||
assert_that(renderer.color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_deduplicates_same_position() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
# Same position again — should reset timer, not add a duplicate
|
||||
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_elapsed_starts_at_zero() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Voice"}])
|
||||
assert_that(renderer._indicators[0].elapsed).is_equal_approx(0.0, 0.001)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_events_expire_after_lifetime() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Footstep"}])
|
||||
# Manually age the indicator past its lifetime
|
||||
renderer._indicators[0].elapsed = SoundIndicatorRenderer.INDICATOR_LIFETIME + 0.01
|
||||
renderer._process(0.0) # zero delta so no additional aging
|
||||
assert_that(renderer._indicators.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
# -- GameState: medium_sound_events from snapshot --
|
||||
|
||||
func test_game_state_filters_medium_sound_events() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
|
||||
{"x": 8.0, "y": 8.0, "event_type": "Voice", "range_category": "Medium"},
|
||||
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Long"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Voice")
|
||||
|
||||
func test_game_state_medium_sound_events_empty_when_no_field() -> void:
|
||||
GameState.apply_snapshot({"tick": 1})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||||
|
||||
func test_game_state_medium_sound_events_cleared_between_ticks() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [{"x": 5.0, "y": 5.0, "event_type": "Voice", "range_category": "Medium"}]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
# Next tick without sound_events clears them
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- TileRenderer: tile type constants --
|
||||
|
||||
func test_tile_type_map_covers_required_types() -> void:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
## Test suite for #126: Medium-range visual indicators (D-018)
|
||||
## Spec refs:
|
||||
## D-018: Medium-range (outside LOS, nearby) → fog-edge directional indicators
|
||||
## D-049: Z-stack — indicators must render above fog layer (z:900)
|
||||
## D-069: Color palette for indicators (neutral/voice/danger)
|
||||
##
|
||||
## Color spec (sprint brief + D-018/D-069):
|
||||
## Neutral: #c8d0e0 (matches INSERT_COLOR_TEXT — insert visual language)
|
||||
## Voices: #e8c547 (matches ENTITY_COLOR_POI — amber, voice context)
|
||||
## Danger: #d45d5d (matches ENTITY_COLOR_HOSTILE — muted red)
|
||||
##
|
||||
## Test layers:
|
||||
## 1. D-018 Indicator color spec — verifies Constants match the spec
|
||||
## 2. D-018 Range routing — Medium vs Close events belong to different systems
|
||||
## 3. Direction geometry — angle from player position to sound source
|
||||
## 4. Snapshot integration stubs — graceful skip until #126 lands
|
||||
## 5. Indicator script API — graceful skip until Stig creates the script
|
||||
class_name TestSoundIndicators
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: D-018/D-069 Indicator Color Spec
|
||||
# Sound indicators reuse existing Constants to avoid palette drift.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d018_indicator_color_neutral_is_insert_color() -> void:
|
||||
## D-018: Neutral indicator = #c8d0e0 = INSERT_COLOR_TEXT.
|
||||
## Indicators use the insert visual language (diegetic, not world-layer UI).
|
||||
assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0"))
|
||||
|
||||
func test_d018_indicator_color_voices_is_poi_amber() -> void:
|
||||
## D-018/D-069: Voice indicator = #e8c547 = ENTITY_COLOR_POI (amber).
|
||||
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
|
||||
|
||||
func test_d018_indicator_color_danger_is_hostile_red() -> void:
|
||||
## D-018/D-069: Danger indicator = #d45d5d = ENTITY_COLOR_HOSTILE (muted red).
|
||||
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
|
||||
|
||||
func test_d018_three_indicator_colors_are_distinct() -> void:
|
||||
## D-018: All three indicator states must be visually distinct.
|
||||
var neutral: Color = Constants.INSERT_COLOR_TEXT
|
||||
var voice: Color = Constants.ENTITY_COLOR_POI
|
||||
var danger: Color = Constants.ENTITY_COLOR_HOSTILE
|
||||
assert_that(neutral != voice).is_true()
|
||||
assert_that(neutral != danger).is_true()
|
||||
assert_that(voice != danger).is_true()
|
||||
|
||||
func test_d049_fog_layer_z_index() -> void:
|
||||
## D-049: Fog overlay is at z:900. Indicators must render above it (z > 900).
|
||||
## This verifies the z constant — indicator placement depends on it.
|
||||
assert_that(Constants.Z_FOG).is_equal(900)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-018 Range Routing — Medium vs Close
|
||||
# Close-range events → AudioManager.play_at() (spatial audio, no indicator)
|
||||
# Medium-range events → fog-edge indicators (no direct audio)
|
||||
# ==============================================================================
|
||||
|
||||
func test_d018_medium_range_events_stored_in_snapshot() -> void:
|
||||
## GameState.medium_sound_events receives Medium-range events (#126 implemented).
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Footstep")
|
||||
|
||||
func test_d018_close_range_events_do_not_appear_in_medium() -> void:
|
||||
## D-018: Close events must NOT appear in medium_sound_events.
|
||||
## They go to close_sound_events for 2D positional audio.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 3.0, "y": 3.0, "event_type": "Voice", "range_category": "Close"},
|
||||
{"x": 15.0, "y": 15.0, "event_type": "Footstep", "range_category": "Medium"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
|
||||
func test_d018_medium_range_event_has_position_fields() -> void:
|
||||
## Medium-range events must include source position for direction calculation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 12.0, "y": 8.0, "event_type": "Footstep", "range_category": "Medium"},
|
||||
]
|
||||
})
|
||||
var ev: Dictionary = GameState.medium_sound_events[0]
|
||||
assert_that(ev.has("x")).is_true()
|
||||
assert_that(ev.has("y")).is_true()
|
||||
assert_that(float(ev["x"])).is_equal_approx(12.0, 0.001)
|
||||
assert_that(float(ev["y"])).is_equal_approx(8.0, 0.001)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Direction Geometry
|
||||
# The fog-edge indicator must point toward the sound source from the player.
|
||||
# These tests validate the math used by the indicator rendering — if the
|
||||
# indicator uses Vector2.angle() or atan2, these confirm expected output.
|
||||
# ==============================================================================
|
||||
|
||||
## Compute directional angle (radians) from player tile pos to source tile pos.
|
||||
## 0 = East, -PI/2 = North, PI/2 = South, ±PI = West (Godot Y-down convention).
|
||||
func _direction_angle(player: Vector2, source: Vector2) -> float:
|
||||
return player.direction_to(source).angle()
|
||||
|
||||
func test_indicator_direction_north() -> void:
|
||||
## Source directly north of player (lower y in Godot) → angle -PI/2.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
|
||||
assert_that(angle).is_equal_approx(-PI / 2.0, 0.01)
|
||||
|
||||
func test_indicator_direction_east() -> void:
|
||||
## Source directly east → angle 0.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
|
||||
assert_that(angle).is_equal_approx(0.0, 0.01)
|
||||
|
||||
func test_indicator_direction_south() -> void:
|
||||
## Source directly south (higher y) → angle PI/2.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
|
||||
assert_that(angle).is_equal_approx(PI / 2.0, 0.01)
|
||||
|
||||
func test_indicator_direction_west() -> void:
|
||||
## Source directly west → angle ≈ ±PI.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 10.0))
|
||||
assert_that(absf(angle)).is_equal_approx(PI, 0.01)
|
||||
|
||||
func test_indicator_direction_northeast() -> void:
|
||||
## Source equal offset northeast → angle -PI/4.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 0.0))
|
||||
assert_that(angle).is_equal_approx(-PI / 4.0, 0.01)
|
||||
|
||||
func test_indicator_direction_southeast() -> void:
|
||||
## Source equal offset southeast → angle PI/4.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 20.0))
|
||||
assert_that(angle).is_equal_approx(PI / 4.0, 0.01)
|
||||
|
||||
func test_indicator_direction_southwest() -> void:
|
||||
## Source equal offset southwest → angle 3PI/4.
|
||||
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 20.0))
|
||||
assert_that(angle).is_equal_approx(3.0 * PI / 4.0, 0.01)
|
||||
|
||||
func test_indicator_direction_changes_with_source_position() -> void:
|
||||
## Sanity: direction is not degenerate — different source positions give different angles.
|
||||
var north := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
|
||||
var east := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
|
||||
var south := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
|
||||
assert_that(north != east).is_true()
|
||||
assert_that(east != south).is_true()
|
||||
assert_that(south != north).is_true()
|
||||
|
||||
func test_indicator_direction_magnitude_independent_of_distance() -> void:
|
||||
## Direction angle must not vary with distance — only with angle from player.
|
||||
## Source 5 tiles north vs 20 tiles north: same angle.
|
||||
var near := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 5.0))
|
||||
var far := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, -10.0))
|
||||
assert_that(near).is_equal_approx(far, 0.01)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Indicator Script API (graceful stub)
|
||||
# Tests activate when Stig creates the indicator rendering script.
|
||||
# Expected paths checked in order — first match wins.
|
||||
# ==============================================================================
|
||||
|
||||
func _load_indicator_script() -> GDScript:
|
||||
## Load the sound indicator renderer script (#126 implemented).
|
||||
var path: String = "res://scripts/rendering/sound_indicator_renderer.gd"
|
||||
if ResourceLoader.exists(path):
|
||||
return load(path) as GDScript
|
||||
return null
|
||||
|
||||
func test_indicator_script_exists() -> void:
|
||||
## #126: sound_indicator_renderer.gd must exist at the canonical path.
|
||||
var script: GDScript = _load_indicator_script()
|
||||
assert_that(script != null).is_true()
|
||||
|
||||
func test_indicator_has_update_sound_events_method() -> void:
|
||||
## #126: Indicator must expose update_sound_events(events: Array).
|
||||
var script: GDScript = _load_indicator_script()
|
||||
if script == null:
|
||||
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
||||
return
|
||||
var method_names: Array = script.get_script_method_list().map(
|
||||
func(m: Dictionary) -> String: return m.name
|
||||
)
|
||||
assert_that(method_names.has("update_sound_events")).is_true()
|
||||
|
||||
func test_indicator_does_not_crash_for_empty_events() -> void:
|
||||
## Edge case: empty sound_events list must not crash update_sound_events.
|
||||
var script: GDScript = _load_indicator_script()
|
||||
if script == null:
|
||||
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
||||
return
|
||||
var indicator := Node2D.new()
|
||||
indicator.set_script(script)
|
||||
add_child(indicator)
|
||||
indicator.update_sound_events([])
|
||||
# Empty update on fresh renderer — no indicators exist, no crash
|
||||
assert_that(indicator._indicators.size()).is_equal(0)
|
||||
indicator.queue_free()
|
||||
|
||||
func test_indicator_existing_survive_empty_update() -> void:
|
||||
## Existing indicators persist through an empty update (expire via _process only).
|
||||
var script: GDScript = _load_indicator_script()
|
||||
if script == null:
|
||||
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
||||
return
|
||||
var indicator := Node2D.new()
|
||||
indicator.set_script(script)
|
||||
add_child(indicator)
|
||||
indicator.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
|
||||
assert_that(indicator._indicators.size()).is_equal(1)
|
||||
indicator.update_sound_events([])
|
||||
assert_that(indicator._indicators.size()).is_equal(1)
|
||||
indicator.queue_free()
|
||||
|
||||
func test_indicator_stores_valid_medium_events() -> void:
|
||||
## #126: update_sound_events must store events with x and y fields.
|
||||
var script: GDScript = _load_indicator_script()
|
||||
if script == null:
|
||||
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
||||
return
|
||||
var indicator := Node2D.new()
|
||||
indicator.set_script(script)
|
||||
add_child(indicator)
|
||||
indicator.update_sound_events([
|
||||
{"x": 10.0, "y": 5.0, "event_type": "Footstep"},
|
||||
{"x": 20.0, "y": 15.0, "event_type": "Voice"},
|
||||
])
|
||||
assert_that(indicator._indicators.size()).is_equal(2)
|
||||
indicator.queue_free()
|
||||
@@ -1,8 +1,23 @@
|
||||
extends Control
|
||||
|
||||
## #495: WRONG button (F12) MVP — bug report capture dialog.
|
||||
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
|
||||
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
|
||||
## Upgrade of the Sprint 9 MVP (#495).
|
||||
##
|
||||
## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause.
|
||||
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
|
||||
## - snapshot.json — single-tick point-in-time (MVP compat)
|
||||
## - render.txt — client-side text render
|
||||
## - description.txt — tester notes + room/tick/seed metadata
|
||||
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
|
||||
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
|
||||
## - seed.txt — RNG seed for deterministic replay
|
||||
##
|
||||
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
|
||||
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
|
||||
## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd).
|
||||
##
|
||||
## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs).
|
||||
## Format: one JSON array per line, each array = Vec<PlayerInput> for that tick.
|
||||
|
||||
signal capture_completed
|
||||
signal capture_cancelled
|
||||
@@ -16,14 +31,172 @@ const BOX_WIDTH := 500
|
||||
const BOX_HEIGHT := 120
|
||||
const PADDING := 16
|
||||
|
||||
# #507: Ring buffer capacity — 60 ticks of history
|
||||
const RING_SIZE := 60
|
||||
|
||||
var _line_edit: LineEdit = null
|
||||
var _active: bool = false
|
||||
|
||||
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
|
||||
# Input ring: replay-format PlayerInput arrays, one per tick.
|
||||
# Snapshot ring: ObserverSnapshot JSON strings, one per tick.
|
||||
# Separate heads and counts so each buffer can be tested independently.
|
||||
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
|
||||
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
|
||||
# debug tool that is always active during Gauntlet play.
|
||||
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
|
||||
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
|
||||
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
|
||||
|
||||
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
|
||||
var _snapshot_head: int = 0
|
||||
var _snapshot_count: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
# Pre-allocate ring buffers — resize then fill sentinels.
|
||||
# The ring array itself never grows after _ready. Each write replaces the GDScript
|
||||
# reference in an existing slot (not a new allocation of the ring), though the input
|
||||
# Array stored per slot is a fresh ref each tick.
|
||||
_input_ring.resize(RING_SIZE)
|
||||
_snapshot_ring.resize(RING_SIZE)
|
||||
for i in range(RING_SIZE):
|
||||
_input_ring[i] = []
|
||||
_snapshot_ring[i] = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API for main.gd: record one tick's data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
|
||||
## - tick: current server tick number
|
||||
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
|
||||
## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded).
|
||||
## These are in raw InputMapper format and will be converted to replay format.
|
||||
func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void:
|
||||
# Convert mapper inputs to replay-compatible format, then push both buffers.
|
||||
var replay_inputs: Array = []
|
||||
for inp in mapper_inputs:
|
||||
var ri := _to_replay_format(inp, tick)
|
||||
if not ri.is_empty():
|
||||
replay_inputs.append(ri)
|
||||
_push_tick_inputs(tick, replay_inputs)
|
||||
_push_tick_snapshot(snapshot_json)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal ring buffer operations (also exposed for tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts.
|
||||
## Overwrites oldest entry when buffer is full (circular eviction).
|
||||
@warning_ignore("unused_parameter")
|
||||
func _push_tick_inputs(_tick: int, inputs: Array) -> void:
|
||||
_input_ring[_input_head] = inputs
|
||||
_input_head = (_input_head + 1) % RING_SIZE
|
||||
if _input_count < RING_SIZE:
|
||||
_input_count += 1
|
||||
|
||||
|
||||
## Push a JSON-serialized ObserverSnapshot string for one tick.
|
||||
func _push_tick_snapshot(snapshot_json: String) -> void:
|
||||
_snapshot_ring[_snapshot_head] = snapshot_json
|
||||
_snapshot_head = (_snapshot_head + 1) % RING_SIZE
|
||||
if _snapshot_count < RING_SIZE:
|
||||
_snapshot_count += 1
|
||||
|
||||
|
||||
## Format the input ring buffer as JSONL for writing to inputs.jsonl.
|
||||
## Returns a String with one JSON array per line, oldest to newest.
|
||||
## Each line: Array of {tick, action} replay-format PlayerInput objects.
|
||||
func _format_inputs_jsonl() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE
|
||||
for i in range(_input_count):
|
||||
var idx := (start + i) % RING_SIZE
|
||||
lines.append(JSON.stringify(_input_ring[idx]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl.
|
||||
## Returns a String with one JSON string per line, oldest to newest.
|
||||
func _format_snapshots_jsonl() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE
|
||||
for i in range(_snapshot_count):
|
||||
var idx := (start + i) % RING_SIZE
|
||||
lines.append(_snapshot_ring[idx])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
## Return the RNG seed for seed.txt. Never returns null.
|
||||
## Uses GameState.rng_seed if available; falls back to "unavailable" string.
|
||||
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
|
||||
## loses precision above 2^53 via float intermediary. When the server field
|
||||
## lands, consider string-encoding the seed to preserve all 64 bits.
|
||||
func _get_current_seed() -> Variant:
|
||||
if GameState.rng_seed != null:
|
||||
return GameState.rng_seed
|
||||
return "unavailable"
|
||||
|
||||
|
||||
## Convert one InputMapper dict to replay-compatible PlayerInput dict.
|
||||
## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU).
|
||||
## Replay format: {"tick": N, "action": "MoveNorth"} or
|
||||
## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}}
|
||||
func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
|
||||
var action_enum: int = input.get("action", -1)
|
||||
var wire: String = SimBridge.action_enum_to_wire(action_enum)
|
||||
if wire.is_empty():
|
||||
return {} # Client-only action (BUG_REPORT, OPEN_MENU)
|
||||
|
||||
var result := {"tick": tick}
|
||||
var action_data: Variant = input.get("action_data")
|
||||
|
||||
match wire:
|
||||
"Interact":
|
||||
# Rust PlayerAction::Interact { target_entity_id, verb }
|
||||
result["action"] = {"Interact": action_data if action_data is Dictionary else {}}
|
||||
"SetFacing":
|
||||
# Rust PlayerAction::SetFacing(String) — wrap direction string
|
||||
var facing := ""
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
result["action"] = {"SetFacing": facing}
|
||||
_:
|
||||
# Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc.
|
||||
result["action"] = wire
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-accessible accessors (ring buffer introspection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _get_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
func _get_snapshot_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
func _get_input_buffer() -> Array:
|
||||
return _input_ring
|
||||
|
||||
func _get_filled_input_count() -> int:
|
||||
return _input_count
|
||||
|
||||
func _get_filled_snapshot_count() -> int:
|
||||
return _snapshot_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI / capture flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func start_capture() -> void:
|
||||
if _active:
|
||||
@@ -94,7 +267,7 @@ func _save_report(description: String) -> void:
|
||||
|
||||
var files_saved := 0
|
||||
|
||||
# 1. snapshot.json — full current snapshot as JSON
|
||||
# 1. snapshot.json — single-tick point-in-time (MVP compat, #495)
|
||||
var snapshot_path := base_path + "/snapshot.json"
|
||||
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
|
||||
if snapshot_file:
|
||||
@@ -115,6 +288,7 @@ func _save_report(description: String) -> void:
|
||||
push_error("BugReport: failed to write %s" % render_path)
|
||||
|
||||
# 3. description.txt — tester description + metadata
|
||||
# Room: uses GameState.room_id (v0.1: room name is the map identifier)
|
||||
var desc_path := base_path + "/description.txt"
|
||||
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
|
||||
if desc_file:
|
||||
@@ -125,12 +299,53 @@ func _save_report(description: String) -> void:
|
||||
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
|
||||
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
|
||||
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
|
||||
desc_file.store_string("RingBufferTicks: %d\n" % _input_count)
|
||||
desc_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % desc_path)
|
||||
|
||||
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
|
||||
# 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible)
|
||||
# One JSON array per line. Empty array = idle tick.
|
||||
# Compatible with tooling/test-client --replay (replay.rs).
|
||||
var inputs_path := base_path + "/inputs.jsonl"
|
||||
var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE)
|
||||
if inputs_file:
|
||||
inputs_file.store_string(_format_inputs_jsonl())
|
||||
inputs_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % inputs_path)
|
||||
|
||||
# 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest.
|
||||
var snaps_path := base_path + "/snapshots.jsonl"
|
||||
var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE)
|
||||
if snaps_file:
|
||||
snaps_file.store_string(_format_snapshots_jsonl())
|
||||
snaps_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % snaps_path)
|
||||
|
||||
# 6. seed.txt — RNG seed for deterministic replay.
|
||||
# Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated.
|
||||
# If absent: includes a note on the required protocol change.
|
||||
var seed_path := base_path + "/seed.txt"
|
||||
var seed_file := FileAccess.open(seed_path, FileAccess.WRITE)
|
||||
if seed_file:
|
||||
var seed_val: Variant = _get_current_seed()
|
||||
seed_file.store_string(str(seed_val) + "\n")
|
||||
if seed_val == "unavailable":
|
||||
seed_file.store_string(
|
||||
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
|
||||
)
|
||||
seed_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % seed_path)
|
||||
|
||||
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
|
||||
files_saved, base_path, _input_count])
|
||||
|
||||
|
||||
## Simplified client-side text render of the current snapshot.
|
||||
@@ -200,7 +415,9 @@ func _draw() -> void:
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
+399
-83
@@ -1,37 +1,67 @@
|
||||
extends Control
|
||||
|
||||
# Dialogue box — D-061: bottom screen, max 20% height, no portraits.
|
||||
# #535: Unified conversation log — player dialogue and overheard NPC-NPC conversations
|
||||
# flow chronologically. Oldest at top, scrolls up. Options at bottom.
|
||||
#
|
||||
# InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled.
|
||||
# NPC speech top, player response options below, left-aligned.
|
||||
# Max 3 visible options. No close button — walk-away (WASD) or option select only.
|
||||
# Auto-pause in single-player when dialogue is open (D-061).
|
||||
# D-063: Confrontation options render italic, trigger monologue beat before send.
|
||||
# D-064: Walk-away via WASD dismisses active conversation (log entries persist).
|
||||
# dialogue_active held until fade completes (D-064 auto-pause spec).
|
||||
# D-078: Overheard NPC-NPC lines prefixed with ┃ glyph + desaturated colours.
|
||||
|
||||
signal option_selected(response_id: String, text: String)
|
||||
signal dialogue_dismissed # Walk-away or conversation end
|
||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||
signal unpause_requested # D-061: auto-unpause
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
|
||||
# -- Log state --
|
||||
# Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec}
|
||||
# pinned entries do not expire (active conversation lines, Araminta review).
|
||||
var _log_entries: Array[Dictionary] = []
|
||||
var _in_player_conversation: bool = false
|
||||
var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1)
|
||||
|
||||
# -- Option state --
|
||||
var _option_controls: Array[Control] = []
|
||||
var _option_response_ids: Array[String] = []
|
||||
var _option_texts: Array[String] = []
|
||||
var _option_is_confrontation: Array[bool] = []
|
||||
var _npc_name: String = ""
|
||||
|
||||
# -- UI state --
|
||||
var _is_showing: bool = false
|
||||
var _active_tween: Tween = null
|
||||
var _beat_tween: Tween = null # D-063: confrontation beat delay
|
||||
var _option_controls: Array[Control] = []
|
||||
var _option_response_ids: Array[String] = [] # response_id per option, same index
|
||||
var _option_texts: Array[String] = [] # raw display text per option
|
||||
var _option_is_confrontation: Array[bool] = [] # confrontation flag per option
|
||||
var _npc_name: String = ""
|
||||
|
||||
# -- Theme (loaded from data/dialogue-theme.yaml) --
|
||||
var _player_color: Color = Color("#e0e8ff")
|
||||
var _npc_colors: Array[Color] = []
|
||||
var _arrow_color: Color = Color("#8890a0")
|
||||
var _speech_color: Color = Color("#c8d0e0")
|
||||
var _passive_opacity: float = 0.9
|
||||
var _entry_lifetime: float = 15.0
|
||||
var _entry_fade: float = 3.0
|
||||
|
||||
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||
|
||||
const FADE_IN: float = 0.2
|
||||
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280)
|
||||
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
|
||||
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
|
||||
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
||||
const PLAYER_NAME: String = "You"
|
||||
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
|
||||
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
|
||||
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
|
||||
|
||||
# D-064: movement actions that trigger walk-away
|
||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||
@@ -45,26 +75,81 @@ func _ready() -> void:
|
||||
visible = false
|
||||
_is_showing = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_load_theme()
|
||||
_update_layout()
|
||||
get_viewport().size_changed.connect(_update_layout)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_expire_entries()
|
||||
if _log_dirty:
|
||||
_log_dirty = false
|
||||
_rebuild_log()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _is_showing:
|
||||
return
|
||||
if not _in_player_conversation:
|
||||
return
|
||||
|
||||
# D-064: WASD during dialogue → walk-away, 300ms fade
|
||||
# D-064: WASD during active player dialogue → walk-away
|
||||
if event is InputEventKey and event.pressed:
|
||||
for action in _WALK_AWAY_ACTIONS:
|
||||
if event.is_action_pressed(action):
|
||||
get_viewport().set_input_as_handled()
|
||||
_cancel_beat()
|
||||
hide_dialogue()
|
||||
_end_player_conversation()
|
||||
dialogue_dismissed.emit()
|
||||
return
|
||||
|
||||
|
||||
# Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport.
|
||||
# -- Theme loading --
|
||||
|
||||
func _load_theme() -> void:
|
||||
# Default NPC palette in case file not found
|
||||
_npc_colors = [
|
||||
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"),
|
||||
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"),
|
||||
]
|
||||
|
||||
if not FileAccess.file_exists(THEME_PATH):
|
||||
push_warning("DialogueBox: theme file not found: %s — using defaults" % THEME_PATH)
|
||||
return
|
||||
|
||||
var file := FileAccess.open(THEME_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var strings := UIStrings._parse_yaml(text)
|
||||
|
||||
if strings.has("player_color"):
|
||||
_player_color = Color(strings["player_color"])
|
||||
if strings.has("arrow_color"):
|
||||
_arrow_color = Color(strings["arrow_color"])
|
||||
if strings.has("speech_color"):
|
||||
_speech_color = Color(strings["speech_color"])
|
||||
if strings.has("passive_opacity"):
|
||||
_passive_opacity = float(strings["passive_opacity"])
|
||||
if strings.has("entry_lifetime_seconds"):
|
||||
_entry_lifetime = float(strings["entry_lifetime_seconds"])
|
||||
if strings.has("entry_fade_seconds"):
|
||||
_entry_fade = float(strings["entry_fade_seconds"])
|
||||
|
||||
# Load NPC colors from indexed keys (npc_colors.0 through npc_colors.7)
|
||||
var loaded_colors: Array[Color] = []
|
||||
for i in range(16): # support up to 16 palette entries
|
||||
var key := "npc_colors.%d" % i
|
||||
if strings.has(key):
|
||||
loaded_colors.append(Color(strings[key]))
|
||||
if loaded_colors.size() > 0:
|
||||
_npc_colors = loaded_colors
|
||||
|
||||
|
||||
# -- Responsive layout --
|
||||
|
||||
func _update_layout() -> void:
|
||||
var vp := get_viewport_rect().size
|
||||
var max_h := vp.y * MAX_HEIGHT_RATIO
|
||||
@@ -74,26 +159,140 @@ func _update_layout() -> void:
|
||||
panel.offset_top = -max_h
|
||||
|
||||
|
||||
# Show dialogue with NPC speech and response options.
|
||||
# npc_name: who is speaking (displayed as prefix)
|
||||
# speech: the NPC's dialogue text
|
||||
# options: Array of {text, response_id, priority, confrontation} — sorted by priority, max 3 shown.
|
||||
# D-062: locked options are invisible (server filters before sending).
|
||||
# D-063: confrontation options render italic.
|
||||
# -- Log entry management --
|
||||
|
||||
## Append a dialogue line to the log.
|
||||
## speaker/target: display names. text: the spoken line.
|
||||
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
|
||||
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
|
||||
func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void:
|
||||
var pinned := not is_passive and _in_player_conversation
|
||||
_log_entries.append({
|
||||
"speaker": speaker,
|
||||
"target": target,
|
||||
"text": text,
|
||||
"is_passive": is_passive,
|
||||
"pinned": pinned,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
_log_dirty = true
|
||||
_ensure_visible()
|
||||
|
||||
|
||||
## Append an overheard conversation event (D-078).
|
||||
func append_conversation_event(event: Dictionary) -> void:
|
||||
var speaker: String = event.get("speaker_name", "?")
|
||||
var target: String = event.get("target_name", "?")
|
||||
var text: String = event.get("occluded_line", "")
|
||||
if text.is_empty():
|
||||
return
|
||||
append_line(speaker, target, text, true)
|
||||
|
||||
|
||||
## Handle conversation_ended — no-op currently (entries expire via timeout).
|
||||
func on_conversation_ended(_event: Dictionary) -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Append the player's chosen response to the log.
|
||||
func append_player_line(target_npc: String, text: String) -> void:
|
||||
append_line(PLAYER_NAME, target_npc, text, false)
|
||||
|
||||
|
||||
## Append an NPC follow-up line (from dialogue_response).
|
||||
func append_dialogue_response(npc_name: String, text: String) -> void:
|
||||
append_line(npc_name, PLAYER_NAME, text, false)
|
||||
|
||||
|
||||
# -- Active player conversation --
|
||||
|
||||
## Show dialogue with NPC speech and response options.
|
||||
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
|
||||
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
|
||||
_npc_name = npc_name
|
||||
_cancel_beat()
|
||||
_in_player_conversation = true
|
||||
|
||||
# NPC speech — name prefix in bold
|
||||
if npc_name.is_empty():
|
||||
npc_speech.text = speech
|
||||
else:
|
||||
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
|
||||
# Append NPC's line to the log
|
||||
if not speech.is_empty():
|
||||
append_line(npc_name, PLAYER_NAME, speech, false)
|
||||
|
||||
# Clear old options
|
||||
# Clear old options and show new ones
|
||||
_clear_options()
|
||||
_show_options(options)
|
||||
|
||||
# Sort by priority ascending, cap at MAX_OPTIONS (D-061: max 3 visible)
|
||||
# Show panel
|
||||
_ensure_visible()
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
GameState.dialogue_active = true # D-064: block movement while in conversation
|
||||
|
||||
# D-069: Dialogue dip
|
||||
AudioManager.apply_dip("dialogue")
|
||||
|
||||
# D-061: auto-pause — signal to main.gd for input recording (#507)
|
||||
pause_requested.emit()
|
||||
|
||||
|
||||
## End active player conversation — clears options but preserves log.
|
||||
## D-064: dialogue_active held until fade completes.
|
||||
func _end_player_conversation() -> void:
|
||||
_in_player_conversation = false
|
||||
_clear_options()
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# Unpin all entries and reset their timestamps so timeout starts now (Araminta review)
|
||||
var now := Time.get_ticks_msec()
|
||||
for entry in _log_entries:
|
||||
if entry.pinned:
|
||||
entry.pinned = false
|
||||
entry.timestamp_msec = now
|
||||
_log_dirty = true
|
||||
|
||||
# D-069: Clear dialogue/confrontation dip
|
||||
AudioManager.clear_dip()
|
||||
|
||||
# D-061: unpause — signal to main.gd for input recording (#507)
|
||||
unpause_requested.emit()
|
||||
|
||||
# D-064: dialogue_active stays true — cleared after fade completes in hide callback.
|
||||
# If log entries still exist, panel stays visible and entries expire via timeout.
|
||||
# If no entries, hide immediately with fade.
|
||||
if _log_entries.is_empty():
|
||||
hide_dialogue()
|
||||
|
||||
|
||||
## Hide the entire panel with fade.
|
||||
func hide_dialogue() -> void:
|
||||
if not _is_showing:
|
||||
return
|
||||
|
||||
if _in_player_conversation:
|
||||
_end_player_conversation()
|
||||
return # _end_player_conversation may call hide_dialogue if log is empty
|
||||
|
||||
_is_showing = false
|
||||
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
|
||||
_active_tween.tween_callback(func():
|
||||
visible = false
|
||||
GameState.dialogue_active = false # D-064: unblock movement after fade completes
|
||||
)
|
||||
|
||||
|
||||
func is_dialogue_active() -> bool:
|
||||
return _in_player_conversation or GameState.dialogue_active
|
||||
|
||||
|
||||
func has_active_entries() -> bool:
|
||||
return _log_entries.size() > 0 or _in_player_conversation
|
||||
|
||||
|
||||
# -- Options --
|
||||
|
||||
func _show_options(options: Array) -> void:
|
||||
var sorted_opts: Array = options.duplicate()
|
||||
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
|
||||
var count := mini(sorted_opts.size(), MAX_OPTIONS)
|
||||
@@ -103,7 +302,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
var raw_text: String = opt.get("text", "")
|
||||
var is_confrontation: bool = opt.get("confrontation", false)
|
||||
|
||||
# RichTextLabel for BBCode support (D-063: confrontation italic)
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
@@ -117,13 +315,11 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
else:
|
||||
label.text = raw_text
|
||||
|
||||
# Click handling
|
||||
var idx := i
|
||||
label.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
# Hover color
|
||||
label.mouse_entered.connect(_make_hover_on(label))
|
||||
label.mouse_exited.connect(_make_hover_off(label))
|
||||
|
||||
@@ -133,46 +329,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
_option_texts.append(raw_text)
|
||||
_option_is_confrontation.append(is_confrontation)
|
||||
|
||||
# Show with fade
|
||||
visible = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_is_showing = true
|
||||
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
|
||||
|
||||
# D-061: auto-pause in single-player when dialogue opens
|
||||
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
# Hide dialogue with fade (D-064: 300ms)
|
||||
func hide_dialogue() -> void:
|
||||
if not _is_showing:
|
||||
return
|
||||
|
||||
_is_showing = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# D-061: unpause when dialogue closes
|
||||
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
|
||||
_active_tween.tween_callback(func():
|
||||
visible = false
|
||||
_clear_options()
|
||||
GameState.dialogue_active = false # D-064: unblock movement after fade completes
|
||||
)
|
||||
|
||||
|
||||
func is_dialogue_active() -> bool:
|
||||
return _is_showing or GameState.dialogue_active
|
||||
|
||||
|
||||
func _on_option_pressed(index: int) -> void:
|
||||
if index >= _option_controls.size():
|
||||
@@ -181,46 +337,40 @@ func _on_option_pressed(index: int) -> void:
|
||||
var text: String = _option_texts[index] if index < _option_texts.size() else ""
|
||||
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
|
||||
|
||||
# Append player's chosen response to the log
|
||||
append_player_line(_npc_name, text)
|
||||
|
||||
if is_confront:
|
||||
_start_confrontation_beat(rid, text)
|
||||
else:
|
||||
option_selected.emit(rid, text)
|
||||
hide_dialogue()
|
||||
_end_player_conversation()
|
||||
|
||||
|
||||
# D-063: Confrontation beat — delay before sending response.
|
||||
# 1. Dim dialogue to 70%, show monologue, dip audio
|
||||
# 2. Wait CONFRONTATION_BEAT_DURATION
|
||||
# 3. Emit option_selected, restore audio, hide dialogue
|
||||
# -- Confrontation beat (D-063) --
|
||||
|
||||
func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
# Disable option clicks during beat
|
||||
for ctrl in _option_controls:
|
||||
if is_instance_valid(ctrl):
|
||||
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# Dim dialogue box
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
# D-063: monologue beat — text from ui-strings.yaml (D-042)
|
||||
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
||||
|
||||
# D-063: audio dip via AudioManager
|
||||
AudioManager.apply_dip("confrontation")
|
||||
|
||||
# Delay, then complete
|
||||
_beat_tween = create_tween()
|
||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||
_beat_tween.tween_callback(func():
|
||||
AudioManager.clear_dip()
|
||||
option_selected.emit(response_id, text)
|
||||
hide_dialogue()
|
||||
_end_player_conversation()
|
||||
)
|
||||
|
||||
|
||||
# Cancel an in-flight confrontation beat (e.g. player walks away mid-beat).
|
||||
func _cancel_beat() -> void:
|
||||
if _beat_tween and _beat_tween.is_valid():
|
||||
_beat_tween.kill()
|
||||
@@ -228,6 +378,172 @@ func _cancel_beat() -> void:
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
## Rebuild the dialogue log BBCode from all non-expired entries.
|
||||
func _rebuild_log() -> void:
|
||||
if not dialogue_log:
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
var bbcode := ""
|
||||
for entry in _log_entries:
|
||||
var alpha: float = 1.0
|
||||
if not entry.pinned:
|
||||
var age_sec: float = (now - entry.timestamp_msec) / 1000.0
|
||||
if age_sec > _entry_lifetime:
|
||||
alpha = clampf(1.0 - (age_sec - _entry_lifetime) / _entry_fade, 0.0, 1.0)
|
||||
if entry.is_passive:
|
||||
alpha *= _passive_opacity
|
||||
if alpha <= 0.0:
|
||||
continue
|
||||
var line := _format_entry(entry, alpha)
|
||||
if not bbcode.is_empty():
|
||||
bbcode += "\n"
|
||||
bbcode += line
|
||||
dialogue_log.text = bbcode
|
||||
|
||||
|
||||
## Format a single log entry as BBCode.
|
||||
## Hoshe #2: escape BBCode brackets in server-sourced strings.
|
||||
## Araminta: passive lines get ┃ prefix + desaturated colours.
|
||||
## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You).
|
||||
func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
var speaker: String = _escape_bbcode(entry.speaker)
|
||||
var target: String = _escape_bbcode(entry.target)
|
||||
var text: String = _escape_bbcode(entry.text)
|
||||
var is_passive: bool = entry.is_passive
|
||||
|
||||
var speaker_color := _color_for_name(entry.speaker)
|
||||
var target_color := _color_for_name(entry.target)
|
||||
|
||||
# Desaturate passive name colours (Araminta review)
|
||||
if is_passive:
|
||||
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
|
||||
target_color = _desaturate(target_color, PASSIVE_DESATURATION)
|
||||
|
||||
var sc := _color_with_alpha(speaker_color, alpha)
|
||||
var ac := _color_with_alpha(_arrow_color, alpha)
|
||||
var tc := _color_with_alpha(target_color, alpha)
|
||||
var txc := _color_with_alpha(_speech_color, alpha)
|
||||
|
||||
var prefix := PASSIVE_GLYPH if is_passive else ""
|
||||
|
||||
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
|
||||
var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME
|
||||
if involves_player and not is_passive:
|
||||
# Just "Speaker: text" or "You: text"
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, txc, text
|
||||
]
|
||||
else:
|
||||
# Full "Speaker → Target: text" for overheard
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, ac, tc, target, txc, text
|
||||
]
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]")
|
||||
|
||||
|
||||
## Get a stable color for a character name, with contrast floor enforcement.
|
||||
func _color_for_name(char_name: String) -> Color:
|
||||
if char_name == PLAYER_NAME:
|
||||
return _player_color
|
||||
if _npc_colors.is_empty():
|
||||
return _speech_color
|
||||
var idx := absi(char_name.hash()) % _npc_colors.size()
|
||||
var color := _npc_colors[idx]
|
||||
return _enforce_contrast(color)
|
||||
|
||||
|
||||
## Enforce minimum luminance so name colours remain readable against dark BG (Araminta #3).
|
||||
static func _enforce_contrast(color: Color) -> Color:
|
||||
var lum := color.r * 0.299 + color.g * 0.587 + color.b * 0.114
|
||||
if lum < MIN_CONTRAST_LUMINANCE:
|
||||
var boost := MIN_CONTRAST_LUMINANCE / maxf(lum, 0.001)
|
||||
return Color(
|
||||
minf(color.r * boost, 1.0),
|
||||
minf(color.g * boost, 1.0),
|
||||
minf(color.b * boost, 1.0),
|
||||
color.a
|
||||
)
|
||||
return color
|
||||
|
||||
|
||||
## Desaturate a colour by a factor (0.0 = no change, 1.0 = full greyscale).
|
||||
static func _desaturate(color: Color, amount: float) -> Color:
|
||||
var grey := color.r * 0.299 + color.g * 0.587 + color.b * 0.114
|
||||
return Color(
|
||||
lerpf(color.r, grey, amount),
|
||||
lerpf(color.g, grey, amount),
|
||||
lerpf(color.b, grey, amount),
|
||||
color.a
|
||||
)
|
||||
|
||||
|
||||
## Convert a Color to a hex string with alpha baked in.
|
||||
static func _color_with_alpha(base: Color, alpha: float) -> String:
|
||||
return Color(base.r, base.g, base.b, base.a * alpha).to_html(true)
|
||||
|
||||
|
||||
# -- Entry expiry --
|
||||
|
||||
## Remove fully expired entries. Mark dirty if any fading entries exist.
|
||||
## Pinned entries (active conversation) skip expiry entirely (Araminta #2).
|
||||
func _expire_entries() -> void:
|
||||
if _log_entries.is_empty():
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
var total_lifetime_msec: int = int((_entry_lifetime + _entry_fade) * 1000.0)
|
||||
var removed := false
|
||||
var has_fading := false
|
||||
|
||||
# Remove expired non-pinned entries from the front (oldest first)
|
||||
while _log_entries.size() > 0:
|
||||
var entry: Dictionary = _log_entries[0]
|
||||
if entry.pinned:
|
||||
break # Pinned entries never expire
|
||||
var age: int = now - entry.timestamp_msec
|
||||
if age < total_lifetime_msec:
|
||||
# Check if this entry is in the fading phase
|
||||
if age > int(_entry_lifetime * 1000.0):
|
||||
has_fading = true
|
||||
break
|
||||
_log_entries.remove_at(0)
|
||||
removed = true
|
||||
|
||||
# Check remaining entries for fading state
|
||||
if not has_fading:
|
||||
for entry in _log_entries:
|
||||
if not entry.pinned:
|
||||
var age: int = now - entry.timestamp_msec
|
||||
if age > int(_entry_lifetime * 1000.0):
|
||||
has_fading = true
|
||||
break
|
||||
|
||||
if removed or has_fading:
|
||||
_log_dirty = true
|
||||
|
||||
if removed and _log_entries.is_empty() and not _in_player_conversation:
|
||||
hide_dialogue()
|
||||
|
||||
|
||||
# -- Visibility --
|
||||
|
||||
## Ensure panel is visible (fade in if needed).
|
||||
func _ensure_visible() -> void:
|
||||
if _is_showing:
|
||||
return
|
||||
visible = true
|
||||
_is_showing = true
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _clear_options() -> void:
|
||||
for ctrl in _option_controls:
|
||||
if is_instance_valid(ctrl):
|
||||
@@ -238,7 +554,7 @@ func _clear_options() -> void:
|
||||
_option_is_confrontation.clear()
|
||||
|
||||
|
||||
# Hover callbacks — closures that capture the label reference.
|
||||
# Hover callbacks
|
||||
static func _make_hover_on(label: RichTextLabel) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||
|
||||
@@ -20,9 +20,9 @@ anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -416.0
|
||||
offset_left = -320.0
|
||||
offset_top = -200.0
|
||||
offset_right = 416.0
|
||||
offset_right = 320.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
|
||||
@@ -37,12 +37,14 @@ theme_override_constants/margin_bottom = 14
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
[node name="DialogueLog" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
bbcode_enabled = true
|
||||
text = ""
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
fit_content = false
|
||||
scroll_active = true
|
||||
scroll_following = true
|
||||
|
||||
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
@@ -13,6 +13,8 @@ extends PanelContainer
|
||||
var _is_showing: bool = false
|
||||
var _active_tween: Tween = null
|
||||
var _current_target_id: int = -1
|
||||
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
|
||||
var _insert_active: bool = true
|
||||
|
||||
const FADE_IN: float = 0.15
|
||||
const FADE_OUT: float = 0.15
|
||||
@@ -23,6 +25,11 @@ func _ready() -> void:
|
||||
_is_showing = false
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed)
|
||||
if not _insert_active:
|
||||
if _is_showing:
|
||||
_hide_prompt()
|
||||
return
|
||||
var interactions: Array = GameState.nearby_interactions
|
||||
if interactions.size() > 0:
|
||||
_show_prompt(interactions[0])
|
||||
@@ -69,6 +76,14 @@ func _hide_prompt() -> void:
|
||||
func get_interaction_target() -> int:
|
||||
return _current_target_id
|
||||
|
||||
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
|
||||
## Cursor shape changes still fire on cursor_renderer.gd.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active and _is_showing:
|
||||
_hide_prompt()
|
||||
|
||||
|
||||
## Returns the selected verb kind (v0.1: first verb on nearest, v0.2: radial selection).
|
||||
func get_selected_verb() -> String:
|
||||
var interactions: Array = GameState.nearby_interactions
|
||||
|
||||
+150
-35
@@ -1,48 +1,163 @@
|
||||
extends Control
|
||||
|
||||
# Internal monologue display (per D-015)
|
||||
# Shows character's internal thoughts as text overlay
|
||||
# Internal monologue display — multi-line, priority-queued (per D-016, #122).
|
||||
# Per Tyre architecture review, Sprint 14.
|
||||
#
|
||||
# Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer.
|
||||
# Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is
|
||||
# dropped when the queue is full and an equal-or-higher-priority line arrives
|
||||
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
|
||||
#
|
||||
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
|
||||
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
@onready var text_panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
|
||||
const MAX_VISIBLE: int = 3
|
||||
const MAX_QUEUE: int = 5
|
||||
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
|
||||
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented": { # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline": { # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
||||
var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
|
||||
var fade_timer: float = 0.0
|
||||
var fade_duration: float = 5.0 # Display duration before fade
|
||||
var is_visible: bool = false
|
||||
var _active_tween: Tween = null
|
||||
|
||||
func _ready() -> void:
|
||||
print("MonologueDisplay: Initialized")
|
||||
text_panel.modulate.a = 0.0
|
||||
is_visible = false
|
||||
pass
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Auto-fade after display
|
||||
if is_visible:
|
||||
fade_timer += delta
|
||||
if fade_timer >= fade_duration:
|
||||
_fade_out()
|
||||
# Expire visible lines
|
||||
for slot in _visible.duplicate():
|
||||
slot.expire_timer -= delta
|
||||
if slot.expire_timer <= 0.0:
|
||||
_retire_slot(slot)
|
||||
|
||||
# Show internal monologue text
|
||||
func show_monologue(text: String, duration: float = 5.0) -> void:
|
||||
text_label.text = text
|
||||
fade_duration = duration
|
||||
fade_timer = 0.0
|
||||
is_visible = true
|
||||
# Drain queue into available visible slots (one per stagger interval)
|
||||
if not _queue.is_empty() and _visible.size() < MAX_VISIBLE:
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if now >= _next_fade_in_msec:
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
|
||||
# Cancel any active tween before starting a new one
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3)
|
||||
|
||||
# Fade out the monologue
|
||||
func _fade_out() -> void:
|
||||
if not is_visible:
|
||||
# Display a monologue line.
|
||||
# priority: higher number = more important (default 2; urgent beats normal).
|
||||
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
|
||||
# Empty text is silently ignored — no slot created, no queue entry.
|
||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||
# decoupled from the autoload (D-020 renderer contract).
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var profile := GameState.lattice_profile
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_line(text, duration, priority, is_urgent, profile)
|
||||
else:
|
||||
_enqueue(text, duration, priority, is_urgent, profile)
|
||||
|
||||
is_visible = false
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
var slot := {
|
||||
node = line_node,
|
||||
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
||||
priority = priority,
|
||||
tween = null as Tween,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
|
||||
line_node.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
slot.tween = tween
|
||||
var target_opacity := 1.0 if is_urgent else 0.85
|
||||
tween.tween_property(line_node, "modulate:a", target_opacity, FADE_IN_SEC)
|
||||
|
||||
|
||||
func _retire_slot(slot: Dictionary) -> void:
|
||||
_visible.erase(slot)
|
||||
var node: Node = slot.node
|
||||
var t: Tween = slot.tween
|
||||
if t and t.is_valid():
|
||||
t.kill()
|
||||
var tween := create_tween()
|
||||
tween.tween_property(node, "modulate:a", 0.0, FADE_OUT_SEC)
|
||||
tween.tween_callback(node.queue_free)
|
||||
|
||||
|
||||
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile})
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
else:
|
||||
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
||||
var lowest := _lowest_priority_idx()
|
||||
if priority >= _queue[lowest].priority:
|
||||
_queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile}
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
# else: incoming line is strictly lower priority — silently drop; no sort needed
|
||||
|
||||
|
||||
func _lowest_priority_idx() -> int:
|
||||
var idx := 0
|
||||
for i in range(1, _queue.size()):
|
||||
if _queue[i].priority < _queue[idx].priority:
|
||||
idx = i
|
||||
return idx
|
||||
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
|
||||
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.add_theme_font_size_override("normal_font_size", 13)
|
||||
# Escape [ to prevent BBCode injection from server-sourced text.
|
||||
# [lb] is Godot's BBCode entity for a literal left bracket.
|
||||
var safe_text := text.replace("[", "[lb]")
|
||||
label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), safe_text]
|
||||
|
||||
container.add_child(label)
|
||||
return container
|
||||
|
||||
@@ -2,37 +2,23 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"]
|
||||
|
||||
; Monologue display area — bottom-left of viewport.
|
||||
; Anchors: 5% left margin, 50% max width, 25% height from bottom (spec §3.1, z-layer 7).
|
||||
; Lines are created dynamically inside VBoxContainer by monologue_display.gd.
|
||||
[node name="MonologueDisplay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -150.0
|
||||
grow_horizontal = 2
|
||||
layout_mode = 1
|
||||
anchor_left = 0.05
|
||||
anchor_top = 0.75
|
||||
anchor_right = 0.55
|
||||
anchor_bottom = 0.98
|
||||
grow_horizontal = 1
|
||||
grow_vertical = 0
|
||||
clip_contents = true
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_monologue")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_left = 100.0
|
||||
offset_right = -100.0
|
||||
offset_bottom = 120.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
text = "Internal monologue will appear here..."
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
extends Control
|
||||
|
||||
## #528: Audio settings dialog — 5-bus volume sliders.
|
||||
## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button.
|
||||
## Volumes persist via AudioManager._save_prefs() on each slider change.
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
|
||||
const TITLE_COLOR := Color("#4a9ebb")
|
||||
const FONT_SIZE := 14
|
||||
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_HEIGHT := 340
|
||||
const PADDING := 20
|
||||
const ROW_HEIGHT := 36
|
||||
|
||||
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
|
||||
const BUS_ROWS: Array = [
|
||||
["Music", "Music"],
|
||||
["Ambient", "Ambient"],
|
||||
["World SFX", "WorldSFX"],
|
||||
["Player Actions", "PlayerActions"],
|
||||
["UI Sounds", "UISounds"],
|
||||
]
|
||||
|
||||
var _active: bool = false
|
||||
var _container: VBoxContainer = null
|
||||
|
||||
signal closed
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
|
||||
func open() -> void:
|
||||
if _active:
|
||||
return
|
||||
_active = true
|
||||
visible = true
|
||||
_build_ui()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
if not _active:
|
||||
return
|
||||
_active = false
|
||||
visible = false
|
||||
_destroy_ui()
|
||||
queue_redraw()
|
||||
closed.emit()
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_pos := Vector2(
|
||||
(vp_size.x - BOX_WIDTH) / 2.0,
|
||||
(vp_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
|
||||
_container = VBoxContainer.new()
|
||||
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
|
||||
_container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
|
||||
_container.add_theme_constant_override("separation", 4)
|
||||
add_child(_container)
|
||||
|
||||
for row_data in BUS_ROWS:
|
||||
var label_text: String = row_data[0]
|
||||
var bus_name: String = row_data[1]
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
|
||||
_container.add_child(hbox)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
label.custom_minimum_size = Vector2(150, 0)
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
hbox.add_child(label)
|
||||
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = -40.0
|
||||
slider.max_value = 0.0
|
||||
slider.step = 0.5
|
||||
slider.value = AudioManager.get_volume(bus_name)
|
||||
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(slider)
|
||||
|
||||
var db_label := Label.new()
|
||||
db_label.text = _format_db(slider.value)
|
||||
db_label.custom_minimum_size = Vector2(70, 0)
|
||||
db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
db_label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
db_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
hbox.add_child(db_label)
|
||||
|
||||
slider.value_changed.connect(func(value: float) -> void:
|
||||
AudioManager.set_volume(bus_name, value)
|
||||
db_label.text = _format_db(value)
|
||||
)
|
||||
|
||||
# Spacer
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 8)
|
||||
_container.add_child(spacer)
|
||||
|
||||
# Close button
|
||||
var close_btn := Button.new()
|
||||
close_btn.text = "CLOSE"
|
||||
close_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
close_btn.pressed.connect(close)
|
||||
_container.add_child(close_btn)
|
||||
|
||||
|
||||
func _destroy_ui() -> void:
|
||||
if _container:
|
||||
_container.queue_free()
|
||||
_container = null
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _active:
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
|
||||
# Dim overlay
|
||||
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
|
||||
|
||||
# Dialog box
|
||||
var box_pos := Vector2(
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0,
|
||||
(viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
||||
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
||||
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
|
||||
|
||||
# Title
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_string(font,
|
||||
box_pos + Vector2(PADDING, PADDING + 18),
|
||||
"AUDIO SETTINGS",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
|
||||
|
||||
static func _format_db(db: float) -> String:
|
||||
if db <= -40.0:
|
||||
return "mute"
|
||||
return "%d dB" % int(db)
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"]
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle
|
||||
[node name="SettingsDialog" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_settings")
|
||||
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "dialogue-line.schema.json",
|
||||
"title": "Dialogue Line — canonical D-035 tag taxonomy",
|
||||
"description": "Canonical single-line schema for dialogue and monologue pools. Implements the converged tag taxonomy from D-035 (6 structural + 3 selection + 2 authoring-only tags). Monologue-specific additions (character, trigger, prerequisite) are defined in $defs/monologue_extension. Mood vocabulary renamed Sprint 14 to match voice guide (anxious/frustrated/content/suspicious/warm/hostile/relieved/focused).",
|
||||
"type": "object",
|
||||
"required": ["id", "text", "role", "access", "trust", "situation"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_(d|m)_[0-9]{3}$",
|
||||
"description": "Stable machine-parseable line ID: {template}_{d|m}_{###}. d = dialogue, m = monologue."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "The authored line text."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$",
|
||||
"description": "Template-defined role slug (e.g. dock-worker, bar-owner, player_character). Not NPC name — NPC assignment is runtime."
|
||||
},
|
||||
"access": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["public", "insider", "authority", "peer", "hostile"]
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"description": "D-028 Layer 1: access tiers this line is eligible for. List — a line can be eligible for multiple tiers. Hard filter."
|
||||
},
|
||||
"trust": {
|
||||
"type": "string",
|
||||
"enum": ["surface", "real", "secret"],
|
||||
"description": "D-028 Layer 3: minimum trust tier required. Hard filter. Ordering: surface < real < secret."
|
||||
},
|
||||
"situation": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"arrival",
|
||||
"shift_start",
|
||||
"shift_end",
|
||||
"shift_transition",
|
||||
"bar_evening",
|
||||
"night_shift",
|
||||
"investigation",
|
||||
"confrontation",
|
||||
"social",
|
||||
"alone",
|
||||
"emergency",
|
||||
"routine",
|
||||
"observation",
|
||||
"greeting"
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"description": "D-028 Layer 2: situations in which this line can fire. 14 v0.1 values (13 original + greeting added Sprint 8 for PC dialogue initial contact lines). NOTE: 'greeting' is not yet in server/src/content/line_pool.rs — lines using it will be skipped until Rust is updated."
|
||||
},
|
||||
"topic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"colleague",
|
||||
"routine",
|
||||
"cargo",
|
||||
"money",
|
||||
"trust",
|
||||
"danger",
|
||||
"institution",
|
||||
"personal",
|
||||
"investigation"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"description": "D-028 Layer 4: topic tags for weighted selection. 9 v0.1 values. Optional — defaults to empty if omitted. Note: 'crime' deliberately excluded; NPCs think of it as 'cargo' or 'money'."
|
||||
},
|
||||
"mood": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"anxious",
|
||||
"frustrated",
|
||||
"content",
|
||||
"suspicious",
|
||||
"warm",
|
||||
"hostile",
|
||||
"relieved",
|
||||
"focused"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"description": "D-028 Layer 4: mood tags for weighted selection. 8 v0.1 values. Neutral mood = omit tag (untagged lines are always eligible). Renamed Sprint 14 to match voice guide vocabulary."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Freeform escape hatch for author intent not covered by the structured taxonomy. Not consumed by the engine selection pipeline."
|
||||
},
|
||||
"knowledge_grant": {
|
||||
"type": "object",
|
||||
"description": "Knowledge the player gains from hearing this line. Feeds into the knowledge graph (D-041).",
|
||||
"required": ["fact_id", "confidence"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fact_id": {
|
||||
"type": "string",
|
||||
"description": "FactId from the knowledge vocabulary (#368)."
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["suspects", "knows_of", "knows_details", "direct"],
|
||||
"description": "D-041 confidence tier granted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dual_lens": {
|
||||
"type": "object",
|
||||
"description": "Authoring-only: per-character notes for content with different resonance for smuggler vs detective. NOT consumed by the engine.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"smuggler": { "type": "string" },
|
||||
"detective": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Authoring-only: freeform author notes, context, or intent documentation. NOT consumed by the engine."
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"monologue_extension": {
|
||||
"title": "Monologue-specific additions (D-035)",
|
||||
"description": "Additional required fields for monologue lines. Applied ON TOP OF the base dialogue line schema. Pool-level character partitioning (D-032) is enforced at the pool root, not per-line.",
|
||||
"type": "object",
|
||||
"required": ["trigger"],
|
||||
"properties": {
|
||||
"character": {
|
||||
"type": "string",
|
||||
"enum": ["smuggler", "detective"],
|
||||
"description": "D-032: hard partition tag. Which playable character this line belongs to. Must match the parent pool's character field."
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enter_location",
|
||||
"observe_npc",
|
||||
"hear_sound",
|
||||
"observe_anomaly",
|
||||
"post_conversation",
|
||||
"discover_evidence",
|
||||
"witness_interaction",
|
||||
"time_idle",
|
||||
"return_visit"
|
||||
],
|
||||
"description": "What causes this monologue line to fire. 9 v0.1 trigger types."
|
||||
},
|
||||
"prerequisite": {
|
||||
"description": "Knowledge state gate. null = unconditional (fires whenever triggered). Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fact_prerequisite" }
|
||||
},
|
||||
"entity_attributes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/attribute_prerequisite" }
|
||||
},
|
||||
"relationship": {
|
||||
"type": "object",
|
||||
"required": ["target", "state"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": { "type": "string" },
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10,
|
||||
"default": 5,
|
||||
"description": "Selection priority. Higher = more likely to fire when multiple lines are eligible. Default: 5."
|
||||
},
|
||||
"cooldown": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0,
|
||||
"description": "Minimum simulation ticks before this line can fire again. Default: 0 (no cooldown)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"fact_prerequisite": {
|
||||
"type": "object",
|
||||
"required": ["fact_id", "min_confidence"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fact_id": {
|
||||
"type": "string",
|
||||
"description": "FactId from the knowledge vocabulary (#368, D-041)."
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["suspects", "knows_of", "knows_details", "direct"],
|
||||
"description": "Minimum D-041 confidence level required for this fact."
|
||||
}
|
||||
}
|
||||
},
|
||||
"attribute_prerequisite": {
|
||||
"type": "object",
|
||||
"required": ["entity", "key", "value"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"entity": { "type": "string" },
|
||||
"key": { "type": "string" },
|
||||
"value": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"situation_enum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"arrival",
|
||||
"shift_start",
|
||||
"shift_end",
|
||||
"shift_transition",
|
||||
"bar_evening",
|
||||
"night_shift",
|
||||
"investigation",
|
||||
"confrontation",
|
||||
"social",
|
||||
"alone",
|
||||
"emergency",
|
||||
"routine",
|
||||
"observation",
|
||||
"greeting"
|
||||
],
|
||||
"description": "14 v0.1 situation values (D-035 + Sprint 8 amendment)."
|
||||
},
|
||||
"topic_enum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"colleague",
|
||||
"routine",
|
||||
"cargo",
|
||||
"money",
|
||||
"trust",
|
||||
"danger",
|
||||
"institution",
|
||||
"personal",
|
||||
"investigation"
|
||||
],
|
||||
"description": "9 v0.1 topic values (D-035)."
|
||||
},
|
||||
"mood_enum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"anxious",
|
||||
"frustrated",
|
||||
"content",
|
||||
"suspicious",
|
||||
"warm",
|
||||
"hostile",
|
||||
"relieved",
|
||||
"focused"
|
||||
],
|
||||
"description": "8 v0.1 mood values. Renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,13 +90,12 @@
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"fond", "comfortable", "worried", "suspicious",
|
||||
"analytical", "conflicted", "concerned", "relieved",
|
||||
"focused"
|
||||
"anxious", "frustrated", "content", "suspicious",
|
||||
"warm", "hostile", "relieved", "focused"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"description": "Mood tags for selection weighting (D-035: list<enum>, v0.1 8 moods + focused added Sprint 8)"
|
||||
"description": "Mood tags for selection weighting (D-035: list<enum>). 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
@@ -106,14 +105,28 @@
|
||||
"knowledge_grant": {
|
||||
"type": "object",
|
||||
"description": "Knowledge the player gains from hearing this line",
|
||||
"required": ["fact_id", "confidence"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fact_id": { "type": "string" },
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["suspects", "knows_of", "knows_details", "direct"]
|
||||
}
|
||||
},
|
||||
"required": ["fact_id", "confidence"]
|
||||
}
|
||||
},
|
||||
"dual_lens": {
|
||||
"type": "object",
|
||||
"description": "Authoring-only: per-character resonance notes (NOT consumed by engine)",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"smuggler": { "type": "string" },
|
||||
"detective": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Authoring-only: freeform author notes (NOT consumed by engine)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "monologue-pool.schema.json",
|
||||
"title": "Monologue Line Pool",
|
||||
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035).",
|
||||
"description": "Tagged monologue lines, hard-partitioned by character (D-032, D-035). Monologue lines carry all 6 D-035 structural tags for schema compliance, but role/access/trust are fixed constants for player-character internal voice (role=player_character, access=[public], trust=surface). The engine does not gate monologue on access or trust — these tags exist for taxonomy uniformity only.",
|
||||
"type": "object",
|
||||
"required": ["character", "location", "lines"],
|
||||
"additionalProperties": false,
|
||||
@@ -10,12 +10,12 @@
|
||||
"character": {
|
||||
"type": "string",
|
||||
"enum": ["smuggler", "detective"],
|
||||
"description": "Playable character this pool belongs to (hard partition per D-032)"
|
||||
"description": "Playable character this pool belongs to (hard partition per D-032). All lines in this pool belong to this character."
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$",
|
||||
"description": "Location slug, or 'general' for location-independent lines"
|
||||
"description": "Location slug, or 'general' for location-independent lines."
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
@@ -26,70 +26,176 @@
|
||||
"$defs": {
|
||||
"monologue_line": {
|
||||
"type": "object",
|
||||
"required": ["id", "text", "trigger"],
|
||||
"required": ["id", "text", "role", "access", "trust", "situation", "trigger"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*_m_[sd]_[0-9]{3}$",
|
||||
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}"
|
||||
"description": "Stable line ID: {location_slug}_m_{s|d}_{###}. s = smuggler, d = detective."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"description": "Line text — 160 char max to fit monologue display without scrolling"
|
||||
"maxLength": 256,
|
||||
"description": "Line text — 256 char hard cap, aim for ≤160 to avoid line wrap"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"const": "player_character",
|
||||
"description": "D-035 structural tag — always player_character for monologue. Monologue is the player character's internal voice."
|
||||
},
|
||||
"access": {
|
||||
"type": "array",
|
||||
"items": { "const": "public" },
|
||||
"minItems": 1,
|
||||
"maxItems": 1,
|
||||
"description": "D-035 structural tag — always [public] for monologue. No access gating applies to internal voice."
|
||||
},
|
||||
"trust": {
|
||||
"type": "string",
|
||||
"const": "surface",
|
||||
"description": "D-035 structural tag — always surface for monologue. No trust gating applies to internal voice."
|
||||
},
|
||||
"situation": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"arrival",
|
||||
"shift_start",
|
||||
"shift_end",
|
||||
"shift_transition",
|
||||
"bar_evening",
|
||||
"night_shift",
|
||||
"investigation",
|
||||
"confrontation",
|
||||
"social",
|
||||
"alone",
|
||||
"emergency",
|
||||
"routine",
|
||||
"observation",
|
||||
"greeting"
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"description": "D-035 structural tag: situations in which this monologue line is contextually appropriate. 14 v0.1 values. The engine selects using trigger; situation provides additional authoring context for filtering by the caller. NOTE: 'greeting' is not yet in server/src/content/line_pool.rs Situation enum."
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"enter_location", "observe_npc", "hear_sound",
|
||||
"observe_anomaly", "post_conversation", "discover_evidence",
|
||||
"witness_interaction", "time_idle", "return_visit"
|
||||
"enter_location",
|
||||
"observe_npc",
|
||||
"hear_sound",
|
||||
"observe_anomaly",
|
||||
"post_conversation",
|
||||
"discover_evidence",
|
||||
"witness_interaction",
|
||||
"time_idle",
|
||||
"return_visit"
|
||||
],
|
||||
"description": "What causes this line to fire"
|
||||
"description": "What causes this line to fire. 9 v0.1 trigger types (D-035 monologue-specific tag)."
|
||||
},
|
||||
"prerequisites": {
|
||||
"type": "object",
|
||||
"description": "AND-only prerequisite conditions",
|
||||
"properties": {
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fact_prerequisite" }
|
||||
},
|
||||
"entity_attributes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/attribute_prerequisite" }
|
||||
},
|
||||
"relationship": {
|
||||
"description": "Knowledge state gate (D-035 monologue-specific tag). null or omitted = unconditional. Conditions are AND-evaluated. Uses FactIds from the knowledge vocabulary (#368, D-041).",
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["target", "state"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": { "type": "string" },
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fact_prerequisite" }
|
||||
},
|
||||
"entity_attributes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/attribute_prerequisite" }
|
||||
},
|
||||
"relationship": {
|
||||
"type": "object",
|
||||
"required": ["target", "state"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"target": { "type": "string" },
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "known", "friendly", "person_of_interest", "hostile"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"topic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"colleague",
|
||||
"routine",
|
||||
"cargo",
|
||||
"money",
|
||||
"trust",
|
||||
"danger",
|
||||
"institution",
|
||||
"personal",
|
||||
"investigation"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"description": "D-035 selection tag: topic tags for weighted selection. 9 v0.1 values. Optional."
|
||||
},
|
||||
"mood": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"anxious",
|
||||
"frustrated",
|
||||
"content",
|
||||
"suspicious",
|
||||
"warm",
|
||||
"hostile",
|
||||
"relieved",
|
||||
"focused"
|
||||
]
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"description": "D-035 selection tag: mood tags for weighted selection. 8 v0.1 values, renamed Sprint 14 to match voice guide vocabulary. Neutral = untagged."
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10,
|
||||
"default": 5,
|
||||
"description": "Selection priority (higher = more likely to fire)"
|
||||
"description": "Selection priority (higher = more likely to fire when multiple lines are eligible). Default: 5."
|
||||
},
|
||||
"cooldown": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Minimum ticks before this line can fire again"
|
||||
"default": 0,
|
||||
"description": "Minimum simulation ticks before this line can fire again. Default: 0."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
"items": { "type": "string" },
|
||||
"description": "D-035 selection tag: freeform tags. Not consumed by the selection pipeline."
|
||||
},
|
||||
"dual_lens": {
|
||||
"type": "object",
|
||||
"description": "Authoring-only: notes on how this line reads differently for smuggler vs detective. NOT consumed by engine.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"smuggler": { "type": "string" },
|
||||
"detective": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Authoring-only: freeform author notes. NOT consumed by engine."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -98,10 +204,14 @@
|
||||
"required": ["fact_id", "min_confidence"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fact_id": { "type": "string" },
|
||||
"fact_id": {
|
||||
"type": "string",
|
||||
"description": "FactId from the knowledge vocabulary (#368, D-041)."
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["suspects", "knows_of", "knows_details", "direct"]
|
||||
"enum": ["suspects", "knows_of", "knows_details", "direct"],
|
||||
"description": "Minimum D-041 confidence level required."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+378
-1
@@ -1,2 +1,379 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: ring-operative at Maintenance Corridors
|
||||
# NPC: Generic ring operative (Tier 3 template — members without individual profiles)
|
||||
# Voice: two registers. Surface: maintenance worker, brief, task-focused. Real: coded, precise, never explicit.
|
||||
# The crucial thing: both registers sound identical to a casual listener.
|
||||
# Ring-aware: full ring membership. Knows the route, the window, the players.
|
||||
# Ring code vocabulary (from Sova texture appendix):
|
||||
# "running maintenance" = cover for ring operation
|
||||
# "the usual pickup" = ring cargo handoff
|
||||
# "cans are ready" = containers cleared for movement
|
||||
# "manifest is clean" = paperwork cover confirmed
|
||||
# "Drin was asking" = warning, inspection proximity
|
||||
# "dead air" = Meridian surveillance gaps
|
||||
# "the gap" = maintenance corridors, the route itself
|
||||
# Ticket: #192 | Sprint: 12
|
||||
|
||||
location: maintenance-corridors
|
||||
role: ring-operative
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# SURFACE COVER — sounds like maintenance work
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_100
|
||||
text: "Running maintenance on the B-7 conduit. Could be a while."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, b7]
|
||||
dual_lens:
|
||||
smuggler: "Cover phrase. He's not doing maintenance."
|
||||
detective: "Maintenance worker. Conduit repair. Routine."
|
||||
notes: "'Running maintenance' is the canonical ring cover phrase."
|
||||
|
||||
- id: maintenance-corridors_d_101
|
||||
text: "Bay four access from here is faster than going around. If you're in a hurry."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, bay-four, spatial]
|
||||
dual_lens:
|
||||
smuggler: "He's telling me the route is faster. This is spatial information, not just courtesy."
|
||||
detective: "Ring operative casually establishes corridor-to-bay-four spatial relationship. Note it."
|
||||
|
||||
- id: maintenance-corridors_d_102
|
||||
text: "Quiet in here tonight. Good working conditions."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, ambient]
|
||||
dual_lens:
|
||||
smuggler: "Dead air confirmed. Meridian's not picking this up."
|
||||
detective: "Routine corridor comment."
|
||||
|
||||
- id: maintenance-corridors_d_103
|
||||
text: "Pael was through here earlier. Seal replacement on the ventilation unit."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, pael, corridor-traffic]
|
||||
dual_lens:
|
||||
smuggler: "Pael was through. Legitimate. Corridor's been active — that means traffic covers us."
|
||||
detective: "References Pael's B-7 presence without suspicion. Maintenance workers know each other's routes."
|
||||
|
||||
- id: maintenance-corridors_d_104
|
||||
text: "C-4 access is at the far end. About three minutes from here."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, c4, spatial]
|
||||
dual_lens:
|
||||
smuggler: "He's giving me the timing. Three minutes to C-4 from this point."
|
||||
detective: "Operative establishes C-4 proximity to maintenance corridor. That's the link."
|
||||
knowledge_grant:
|
||||
fact_id: location.smuggling_route
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# OPERATIONAL COORDINATION — insider/real only
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_105
|
||||
text: "Cans are ready. Bay four, per the schedule."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine, shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, coded, cans-ready]
|
||||
dual_lens:
|
||||
smuggler: "Containers cleared. Bay four. We're on."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_106
|
||||
text: "Manifest is clean. Window opens in eighteen minutes."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, coded, manifest-clean, window]
|
||||
dual_lens:
|
||||
smuggler: "Paperwork's set. Eighteen minutes. That's earlier than planned — adjust."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_107
|
||||
text: "Usual pickup. Route's the same."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition, routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, coded, usual-pickup]
|
||||
dual_lens:
|
||||
smuggler: "Same route, same handoff. No variations."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_108
|
||||
text: "The gap's clear. Scan's done at 14:20. Next window's yours."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, coded, scan-gap, surveillance]
|
||||
dual_lens:
|
||||
smuggler: "14:20 scan, then the window opens. Twenty-five minutes clear."
|
||||
detective: "Never hears this line."
|
||||
knowledge_grant:
|
||||
fact_id: location.surveillance_gaps
|
||||
confidence: knows_details
|
||||
|
||||
- id: maintenance-corridors_d_109
|
||||
text: "Drin was asking about B-7. Twice today."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine, confrontation]
|
||||
topic: [danger, colleague]
|
||||
mood: [concerned]
|
||||
tags: [ring-operative, ring-ops, coded, drin-asking, warning]
|
||||
dual_lens:
|
||||
smuggler: "Drin's circling B-7. That's a warning. Push the pickup or hold it."
|
||||
detective: "Never hears this line."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.drin_inspection_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: maintenance-corridors_d_110
|
||||
text: "Dead air through here to the C-4 junction. No coverage."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, coded, dead-air, surveillance]
|
||||
dual_lens:
|
||||
smuggler: "Full confirmation. Meridian doesn't reach this section."
|
||||
detective: "Never hears this line."
|
||||
knowledge_grant:
|
||||
fact_id: location.surveillance_gaps
|
||||
confidence: knows_details
|
||||
|
||||
- id: maintenance-corridors_d_111
|
||||
text: "Voss confirmed the hold clears at 1400. He'll run the bay himself."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, voss, bay-four, clearance]
|
||||
dual_lens:
|
||||
smuggler: "Voss is personally running bay four at 1400. That's the signal the window is clear."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_112
|
||||
text: "Kael's on dock. He knows."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine, shift_transition]
|
||||
topic: [colleague, routine]
|
||||
mood: [focused]
|
||||
tags: [ring-operative, ring-ops, kael, coordination]
|
||||
dual_lens:
|
||||
smuggler: "Kael's in position. Ring is coordinated."
|
||||
detective: "Never hears this line."
|
||||
|
||||
# ==========================================
|
||||
# PRESSURE / WARNING SIGNALS
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_113
|
||||
text: "Commission eyes on the terminal floor. Not here yet."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [confrontation, investigation]
|
||||
topic: [danger, institution]
|
||||
mood: [concerned]
|
||||
tags: [ring-operative, ring-ops, commission, warning]
|
||||
dual_lens:
|
||||
smuggler: "Detective is visible on the main floor. Corridors are still clear."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_114
|
||||
text: "Hold the run. Drin's in the corridor section."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [confrontation]
|
||||
topic: [danger]
|
||||
mood: [concerned]
|
||||
tags: [ring-operative, ring-ops, drin, abort-signal]
|
||||
dual_lens:
|
||||
smuggler: "Stop. Drin's in position. The window's closed."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_115
|
||||
text: "Maret filed again. Voss has it. Don't move anything tonight."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [confrontation, routine]
|
||||
topic: [danger, institution]
|
||||
mood: [concerned]
|
||||
tags: [ring-operative, ring-ops, maret, voss, caution]
|
||||
dual_lens:
|
||||
smuggler: "Maret flagged something. Voss is managing it. Stand down tonight."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: maintenance-corridors_d_116
|
||||
text: "We're clean. Same time tomorrow."
|
||||
role: ring-operative
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, ring-ops, run-complete, closing]
|
||||
dual_lens:
|
||||
smuggler: "Run's done. No issues. Tomorrow."
|
||||
detective: "Never hears this line."
|
||||
|
||||
# ==========================================
|
||||
# IF DETECTIVE SOMEHOW ENTERS — surface only
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_117
|
||||
text: "Restricted access back here. Service personnel only."
|
||||
role: ring-operative
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [ring-operative, detective-path, access-denial]
|
||||
|
||||
- id: maintenance-corridors_d_118
|
||||
text: "I'm running maintenance. If there's a Commission access request, file it through Voss."
|
||||
role: ring-operative
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [ring-operative, detective-path, voss-redirect]
|
||||
dual_lens:
|
||||
smuggler: "Never encounters this."
|
||||
detective: "Operative redirects to Voss. Everyone redirects to Voss. That's structural, not coincidence."
|
||||
|
||||
- id: maintenance-corridors_d_119
|
||||
text: "Nothing unusual back here. Conduit work. Same every cycle."
|
||||
role: ring-operative
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [routine]
|
||||
mood: [wary]
|
||||
tags: [ring-operative, detective-path, deflection]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — surface cover variants
|
||||
# Surface-only: covers routine corridor presence without operational tells.
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_120
|
||||
text: "Power relay's been cycling weird. I'm watching it."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_121
|
||||
text: "Pael was supposed to handle B-7. He didn't show."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, pael, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_122
|
||||
text: "Watch the floor here. Drain panel's been loose three cycles."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_123
|
||||
text: "Airflow's better down the B-section. C-section's got the recycler issue."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, spatial, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_124
|
||||
text: "You want the service bay, it's left at the junction. Don't go right — that's the restricted access."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, navigation, generation-pass]
|
||||
dual_lens:
|
||||
smuggler: "He's directing people away from the route. Social traffic management."
|
||||
detective: "Operative redirects me away from C-4 access junction instinctively."
|
||||
|
||||
- id: maintenance-corridors_d_125
|
||||
text: "Long shift. These corridors look the same after a while."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_end]
|
||||
topic: [routine]
|
||||
mood: [tired]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
- id: maintenance-corridors_d_126
|
||||
text: "Scan's clear. I checked it an hour ago."
|
||||
role: ring-operative
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ring-operative, surface-cover, generation-pass]
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# Dialogue: transit-worker at Maintenance Corridors
|
||||
# NPC: Tev Osel (Tier 3, NOBODY/CIVILIAN)
|
||||
# Voice: patient, mildly distracted, confused why anyone is asking him anything.
|
||||
# KEY LINE: maintenance-corridors_d_001 — sounds like surveillance positioning inquiry
|
||||
# Actually: Tev missed Kosse at the corridor junction and is looking for them
|
||||
# Ticket: #307 | Sprint: 12
|
||||
|
||||
location: maintenance-corridors
|
||||
role: transit-worker
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# "SEEMS IMPORTANT BUT ISN'T" LINE
|
||||
# Sounds like Tev is checking who came through a surveillance point.
|
||||
# Actually: Tev missed his partner at the regular meeting spot.
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_001
|
||||
text: "You see which way Kosse went? Should've been through here twenty minutes ago."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine, observation]
|
||||
topic: [colleague, personal]
|
||||
mood: [comfortable]
|
||||
tags: [tev, seems-important-but-isnt, waiting, kosse]
|
||||
dual_lens:
|
||||
smuggler: "Someone asking who came through the corridor. Tev. Harmless — checking for a partner."
|
||||
detective: "NPC at corridor junction, tracking who passed through. Possible lookout. Follow up: who is Kosse?"
|
||||
notes: >
|
||||
THE key flat NPC line for Tev. Sounds like Tev monitors corridor traffic
|
||||
for operational reasons. Is someone who missed a meeting time with their
|
||||
partner. The detective's suspicion is reasonable; it resolves to nothing.
|
||||
|
||||
# ==========================================
|
||||
# ROUTINE LINES
|
||||
# ==========================================
|
||||
|
||||
- id: maintenance-corridors_d_002
|
||||
text: "They shifted the freight route again. Always forget which junction."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [tev, atmospheric]
|
||||
|
||||
- id: maintenance-corridors_d_003
|
||||
text: "Just on break. Out of your way in a minute."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [tev, incidental]
|
||||
|
||||
- id: maintenance-corridors_d_004
|
||||
text: "Late freight route runs long. Can't do anything about it."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine, night_shift]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [tev, atmospheric]
|
||||
|
||||
- id: maintenance-corridors_d_005
|
||||
text: "Waiting for someone. Shouldn't be long."
|
||||
role: transit-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine, observation]
|
||||
topic: [personal]
|
||||
mood: [comfortable]
|
||||
tags: [tev, waiting]
|
||||
+381
-1
@@ -1,2 +1,382 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: bar-owner at The Last Shift
|
||||
# NPC: Lera Osk (Tier 2, SYSTEM/OPERATOR)
|
||||
# Voice: direct, dry. Runs her bar like a territory. Not political — protective.
|
||||
# Ring-aware: knows it exists in her bar. Doesn't ask. Doesn't interfere. Not complicit.
|
||||
# Torek arrangement: she gives him a tab specifically — Commission visibility is useful.
|
||||
# Ticket: #191 | Sprint: 12
|
||||
|
||||
location: the-last-shift
|
||||
role: bar-owner
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — territorial welcome
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_100
|
||||
text: "Usual?"
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting, economy-of-words]
|
||||
|
||||
- id: the-last-shift_d_101
|
||||
text: "Long shift?"
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting]
|
||||
|
||||
- id: the-last-shift_d_102
|
||||
text: "Bar's clean, drink's cold. That's the deal."
|
||||
role: bar-owner
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting, house-rules]
|
||||
|
||||
- id: the-last-shift_d_103
|
||||
text: "Sess'll get you. I've got something in the back."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting, redirection]
|
||||
|
||||
# ==========================================
|
||||
# BAR MANAGEMENT — house rules, operations
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_104
|
||||
text: "Fights go outside. First and last warning."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [confrontation, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, house-rules, authority]
|
||||
|
||||
- id: the-last-shift_d_105
|
||||
text: "Tab closes at end of cycle. Not at end of your memory."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, tab, house-rules]
|
||||
|
||||
- id: the-last-shift_d_106
|
||||
text: "Torek's running a tab. That's fine. Don't ask me why."
|
||||
role: bar-owner
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [lera, torek, tab-arrangement]
|
||||
dual_lens:
|
||||
smuggler: "Lera chose to give Torek credit. Commission man. She's keeping him visible and comfortable. Smart."
|
||||
detective: "Bar owner specifically maintains a tab for a Commission officer. Useful relationship or leverage?"
|
||||
notes: "Lera's Torek arrangement: she keeps him comfortable because knowing where the Commission officer is drinking is better than not knowing."
|
||||
|
||||
- id: the-last-shift_d_107
|
||||
text: "Lattice stays off at the bar. Sign's on the wall."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [lera, lattice-free, house-rules]
|
||||
notes: "Not legally enforceable — Lera knows this. It's about atmosphere, not compliance."
|
||||
|
||||
- id: the-last-shift_d_108
|
||||
text: "Grain spirit's back. Limited run. Don't make it last two nights."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, drinks, community]
|
||||
|
||||
# ==========================================
|
||||
# PEOPLE — Lera knows everyone
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_109
|
||||
text: "Kael's in the back. If you're looking."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [colleague, routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, kael, spatial-information]
|
||||
dual_lens:
|
||||
smuggler: "Good. She keeps track. That's what she does."
|
||||
detective: "Lera knows where Kael Davan is without being asked. She tracks her regulars."
|
||||
|
||||
- id: the-last-shift_d_110
|
||||
text: "Naia's been in early the last few nights. Doesn't say much."
|
||||
role: bar-owner
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [lera, naia, observation]
|
||||
dual_lens:
|
||||
smuggler: "Lera noticed Naia's off. She notices everything. She's saying it because she wants someone to do something."
|
||||
detective: "Bar owner volunteers behavioral change in Naia Tamm. Lera observes her regulars carefully."
|
||||
|
||||
- id: the-last-shift_d_111
|
||||
text: "Ren? Been here two months. Quiet, pays their tab. That's all I can tell you."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [lera, ren, drifter]
|
||||
dual_lens:
|
||||
smuggler: "That's Lera's version of a clean assessment. She'd say more if she suspected something."
|
||||
detective: "Lera's read on Ren: quiet, no trouble. If Lera's not worried, the baseline is probably accurate."
|
||||
|
||||
- id: the-last-shift_d_112
|
||||
text: "Tev? Good worker. Three years. Minds their own."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [lera, tev, endorsement]
|
||||
dual_lens:
|
||||
smuggler: "Lera vouching for Tev. She doesn't do that for people she doesn't trust."
|
||||
detective: "Lera's read on Tev is straightforward. 'Minds their own' from Lera means she's tested it."
|
||||
|
||||
# ==========================================
|
||||
# RING-ADJACENT — Lera's knowledge edge
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_113
|
||||
text: "Whatever's happening in the back corner isn't my business. Don't make it my business."
|
||||
role: bar-owner
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [confrontation, alone]
|
||||
topic: [danger, routine]
|
||||
mood: [focused]
|
||||
tags: [lera, ring-ops, willful-ignorance]
|
||||
dual_lens:
|
||||
smuggler: "She knows. She's drawing her line. That's the deal."
|
||||
detective: "Never hears this line."
|
||||
notes: "Lera's arrangement: she doesn't ask, she doesn't see, and she keeps things stable. Pragmatic, not complicit."
|
||||
|
||||
- id: the-last-shift_d_114
|
||||
text: "Kael's got my number. He's been a regular long enough that that means something."
|
||||
role: bar-owner
|
||||
access: [peer]
|
||||
trust: real
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [lera, kael, loyalty]
|
||||
dual_lens:
|
||||
smuggler: "Lera's loyal to Kael. Not to the ring. There's a difference and it matters."
|
||||
detective: "Lera's personal loyalty to Kael Davan is explicit. She'll protect him over process."
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — neutral, unwelcoming
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_115
|
||||
text: "Commission's welcome. Like everyone else. Bar rules apply."
|
||||
role: bar-owner
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [arrival, investigation]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, detective-path, neutral-welcome]
|
||||
|
||||
- id: the-last-shift_d_116
|
||||
text: "If someone in my bar has something to say to Commission, they'll say it. I don't push that."
|
||||
role: bar-owner
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, detective-path, institutional-independence]
|
||||
|
||||
- id: the-last-shift_d_117
|
||||
text: "I've run this bar twelve years without a Commission complaint. I'd like to keep it that way."
|
||||
role: bar-owner
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [lera, detective-path, authority-assertion]
|
||||
|
||||
- id: the-last-shift_d_118
|
||||
text: "I see a lot. I don't always remember all of it."
|
||||
role: bar-owner
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, detective-path, careful-language]
|
||||
dual_lens:
|
||||
smuggler: "Never encounters this line."
|
||||
detective: "Lera is very deliberately declining to volunteer what she knows. That's not ignorance — that's a position."
|
||||
|
||||
# ==========================================
|
||||
# ELEVATED TRUST — what Lera actually says
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_119
|
||||
text: "Naia's been asking questions I can't answer. Someone should."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
trust: real
|
||||
situation: [routine, alone]
|
||||
topic: [colleague, danger]
|
||||
mood: [concerned]
|
||||
tags: [lera, naia, threshold-line]
|
||||
dual_lens:
|
||||
smuggler: "Lera's telling me to do something about Naia. She's right."
|
||||
detective: "Lera is flagging Naia Tamm as someone who's looking for answers. That's a conversation I need to have."
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF / CLOSING TIME
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_120
|
||||
text: "Last call. Finish up."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, closing]
|
||||
|
||||
- id: the-last-shift_d_121
|
||||
text: "Bar's closed. Come back next cycle."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, closing]
|
||||
|
||||
- id: the-last-shift_d_122
|
||||
text: "Good night. Stay out of trouble."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, closing]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_123
|
||||
text: "You look tired. Sit."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_124
|
||||
text: "Same as yesterday?"
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_125
|
||||
text: "New face. Bar rules are on the wall."
|
||||
role: bar-owner
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, greeting, newcomer, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_126
|
||||
text: "No trouble tonight. I mean it."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, house-rules, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_127
|
||||
text: "Sess'll get you settled. I'm in the back."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, redirect, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_128
|
||||
text: "We close at 2300. Don't make me say it twice."
|
||||
role: bar-owner
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, house-rules, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_129
|
||||
text: "Good crowd tonight. Quiet enough."
|
||||
role: bar-owner
|
||||
access: [peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, ambient, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_130
|
||||
text: "Bar's yours. I've got accounts."
|
||||
role: bar-owner
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [lera, ambient, generation-pass]
|
||||
|
||||
+359
-1
@@ -1,2 +1,360 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: bar-regular at The Last Shift
|
||||
# NPC: Generic regular (Tier 3 template role — transit district workers, post-shift)
|
||||
# Voice: off-duty, community-minded. Complains, gossips, opinionates. Belongs here.
|
||||
# Ring-aware: varies. Most know something's off at the terminal. None know specifics.
|
||||
# Ticket: #191 | Sprint: 12
|
||||
|
||||
location: the-last-shift
|
||||
role: bar-regular
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — familiar, community register
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_300
|
||||
text: "Long one today?"
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, greeting]
|
||||
|
||||
- id: the-last-shift_d_301
|
||||
text: "You're in early. Good shift or bad shift?"
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, greeting]
|
||||
|
||||
- id: the-last-shift_d_302
|
||||
text: "Grain spirit's back. I already had two. Don't judge."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, greeting, drinks]
|
||||
|
||||
# ==========================================
|
||||
# TERMINAL FLOOR — off-duty opinions
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_303
|
||||
text: "Push today. Bay six was backed up until 2000. Voss wasn't happy."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, terminal, voss]
|
||||
|
||||
- id: the-last-shift_d_304
|
||||
text: "Maret flagged another one. Nobody knows what Voss does with them."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague, cargo]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, maret, voss, discrepancy]
|
||||
dual_lens:
|
||||
smuggler: "They're talking about Maret's flags as general knowledge. That's a wider problem than I thought."
|
||||
detective: "Maret's pattern is common knowledge on the floor. Multiple witnesses, informal. That can be confirmed."
|
||||
|
||||
- id: the-last-shift_d_305
|
||||
text: "Bay four's been held all week. Three cycles. Nobody's said what's in it."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, bay-four, observation]
|
||||
dual_lens:
|
||||
smuggler: "They've noticed bay four. It's becoming visible. Not good."
|
||||
detective: "Bay four hold is common knowledge at worker level. That means it predates any routine explanation."
|
||||
|
||||
- id: the-last-shift_d_306
|
||||
text: "Transition window's the worst part of the job. Twenty minutes to hand off and nothing ever lines up."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, transition-window, complaint]
|
||||
dual_lens:
|
||||
smuggler: "The window is their biggest frustration. And their biggest frustration is the ring's biggest asset."
|
||||
detective: "Workers discuss the transition window as a systematic failure. The gap is known."
|
||||
|
||||
- id: the-last-shift_d_307
|
||||
text: "Commission audit's coming cycle fifteen. Everyone's running around double-checking everything."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, commission, audit]
|
||||
dual_lens:
|
||||
smuggler: "Cycle fifteen. That's the deadline. Get clean before then."
|
||||
detective: "Workers are responding to the audit announcement. Behavioral change in the workforce. Note who isn't anxious."
|
||||
|
||||
- id: the-last-shift_d_308
|
||||
text: "Voss has been on the floor more than usual this cycle. Watching everything."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, colleague]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, voss, observation]
|
||||
dual_lens:
|
||||
smuggler: "Voss is visible more. Is that heat from above, or is he managing something on the floor?"
|
||||
detective: "Voss behavioral change: increased floor presence. Defensive positioning or active oversight?"
|
||||
|
||||
# ==========================================
|
||||
# COMMUNITY — the full life of this district
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_309
|
||||
text: "Drifters lost again. Four-nil this time. I'm done watching until they rebuild."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, sports, drifters]
|
||||
|
||||
- id: the-last-shift_d_310
|
||||
text: "Ventilation's still the same in Sector 3. Seven years of 'under discussion.' I've stopped expecting anything."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, infrastructure, grievance]
|
||||
|
||||
- id: the-last-shift_d_311
|
||||
text: "You see the missing person notice on the board? Vessels Tamm. I don't know a Vessels Tamm."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [community, routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, vessels-tamm, notice]
|
||||
dual_lens:
|
||||
smuggler: "Vessels Tamm. Naia's surname is Tamm. Don't chase it — but don't ignore it either."
|
||||
detective: "Vessels Tamm notice is circulating casually. People haven't connected it to Naia Tamm yet."
|
||||
|
||||
- id: the-last-shift_d_312
|
||||
text: "Re-em clinic's backed up four cycles for non-urgent. Main station if it's serious, apparently."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [community, routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, clinic, community]
|
||||
|
||||
- id: the-last-shift_d_313
|
||||
text: "Lattice upgrades are up eighteen percent this cycle. That's more than my raise."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, community]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, lattice, economy]
|
||||
dual_lens:
|
||||
smuggler: "That's why the waiting list keeps growing. They're providing a service."
|
||||
detective: "Economic grievance expressed casually. Price pressure is general knowledge. Confirms motive context."
|
||||
|
||||
- id: the-last-shift_d_314
|
||||
text: "Workers' Association is pushing the handoff protocol again. Voss won't budge."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, community]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, workers-association, voss, transition-window]
|
||||
dual_lens:
|
||||
smuggler: "Voss is defending the twenty-minute window against Association pressure. He's protecting the gap."
|
||||
detective: "Voss is actively resisting worker-led protocol improvement. Institutional reason for the gap isn't mechanical."
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — community wariness
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_315
|
||||
text: "You new? Haven't seen you before."
|
||||
role: bar-regular
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [arrival, investigation]
|
||||
topic: [routine]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, detective-path, recognition]
|
||||
|
||||
- id: the-last-shift_d_316
|
||||
text: "Commission? I just drink here. Whatever you're after, I probably can't help."
|
||||
role: bar-regular
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, detective-path, deflection]
|
||||
|
||||
- id: the-last-shift_d_317
|
||||
text: "Ask Lera. She knows what happens in this bar better than anyone."
|
||||
role: bar-regular
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [routine]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, detective-path, lera-redirect]
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_318
|
||||
text: "Another cycle done. Same tomorrow."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, closing]
|
||||
|
||||
- id: the-last-shift_d_319
|
||||
text: "Safe home. Watch the corridors after 2200."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, closing, community]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_320
|
||||
text: "Pull up a chair. Floor's full otherwise."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_321
|
||||
text: "You're in late. Voss kept you?"
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, greeting, voss, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_322
|
||||
text: "Just got off. Bay six all night. My back knows it."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [tired]
|
||||
tags: [bar-regular, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_323
|
||||
text: "Drifters are playing Mirren next cycle. Home game. Might actually go."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, sports, community, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_324
|
||||
text: "Sector 3 meeting went nowhere. Same as last time. Same as the time before that."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, community]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, infrastructure, grievance, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_325
|
||||
text: "New hire's asking questions on the floor again. Good. Someone should."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague, routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, new-hire, community, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_326
|
||||
text: "Span gate was loud today. Three cycles now. That's not normal."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [bar-regular, infrastructure, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_327
|
||||
text: "Kael's not in tonight. Unusual."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, kael, observation, generation-pass]
|
||||
dual_lens:
|
||||
smuggler: "Kael's absence is visible. Other people notice too."
|
||||
detective: "Kael Davan's routine absence is noted socially. Behavioral change is general knowledge."
|
||||
|
||||
- id: the-last-shift_d_328
|
||||
text: "Lera keeps the grain spirit back until she's sure you're a regular. You must've made the grade."
|
||||
role: bar-regular
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, lera, humor, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_329
|
||||
text: "Good night. Don't let the Commission ruin your morning."
|
||||
role: bar-regular
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [bar-regular, closing, generation-pass]
|
||||
|
||||
+310
-1
@@ -1,2 +1,311 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: bartender at The Last Shift
|
||||
# NPC: Sess (Tier 3 template role — Lera's staff)
|
||||
# Voice: practical, pleasant, socially adept. Passes information by reflex, not design.
|
||||
# Ring-aware: none. Knows regulars, patterns, who drinks with whom.
|
||||
# Ticket: #191 | Sprint: 12
|
||||
|
||||
location: the-last-shift
|
||||
role: bartender
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — service register
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_200
|
||||
text: "What'll it be?"
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, greeting]
|
||||
|
||||
- id: the-last-shift_d_201
|
||||
text: "Back early tonight. Long shift?"
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, greeting, social]
|
||||
|
||||
- id: the-last-shift_d_202
|
||||
text: "Grain spirit's back. Lera's running it at cycle price for two more days."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, greeting, drinks]
|
||||
|
||||
# ==========================================
|
||||
# BAR OBSERVATIONS — social intelligence
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_203
|
||||
text: "Kael's been in most nights this week. Quiet though. Not his usual."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [sess, kael, observation]
|
||||
dual_lens:
|
||||
smuggler: "Sess noticed Kael's off. She's reading it as personal. She doesn't know what's underneath."
|
||||
detective: "Bartender flagging behavioral change in Kael Davan. Consistent with other observations."
|
||||
|
||||
- id: the-last-shift_d_204
|
||||
text: "Torek tipped well again tonight. Always does. Nicer than you'd expect for Commission."
|
||||
role: bartender
|
||||
access: [peer, public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, torek, observation]
|
||||
dual_lens:
|
||||
smuggler: "Torek's making friends with the staff. Not good."
|
||||
detective: "Bartender views Torek favorably. Torek is cultivating the service staff deliberately."
|
||||
|
||||
- id: the-last-shift_d_205
|
||||
text: "Naia was in earlier. Didn't stay. Left something on the table — looked like a note. Lera has it."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [sess, naia, note, observation]
|
||||
dual_lens:
|
||||
smuggler: "Naia left something. Lera has it. Go to Lera."
|
||||
detective: "Naia Tamm left written material at the bar. Lera is holding it. That's a material lead."
|
||||
|
||||
- id: the-last-shift_d_206
|
||||
text: "Ren? Yeah, they're always in the morning crowd. Don't say much. Good for the morning."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [sess, ren, drifter]
|
||||
|
||||
- id: the-last-shift_d_207
|
||||
text: "Tev watches the door. Every shift. Lera likes it that way."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague, routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, tev, door, observation]
|
||||
dual_lens:
|
||||
smuggler: "Tev watches the door for Lera. Not for anyone else. That's the setup."
|
||||
detective: "Tev's entrance-monitoring is confirmed as Lera's request. Not operational surveillance."
|
||||
|
||||
- id: the-last-shift_d_208
|
||||
text: "Sera was in earlier. She usually stays later. She left when Torek came in."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague, institution]
|
||||
mood: [comfortable]
|
||||
tags: [sess, sera, torek, observation]
|
||||
dual_lens:
|
||||
smuggler: "Commission in, Commission contact out. Makes sense."
|
||||
detective: "Third data point. Sera leaves when Torek arrives. Bartender confirms the pattern."
|
||||
|
||||
# ==========================================
|
||||
# SOCIAL / COMMUNITY
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_209
|
||||
text: "Drifters lost again. Tev took it hard. Drank two too many."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, tev, sports, community]
|
||||
|
||||
- id: the-last-shift_d_210
|
||||
text: "Workers' Association meeting's Thursday. Half the regulars will be here Friday in a mood either way."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, workers-association, community]
|
||||
|
||||
- id: the-last-shift_d_211
|
||||
text: "Quiet night. Good for everyone."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, ambient]
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — helpful, unfocused
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_212
|
||||
text: "Commission comes in sometimes. Not usually this early in the cycle."
|
||||
role: bartender
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [arrival, investigation]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, detective-path, observation]
|
||||
|
||||
- id: the-last-shift_d_213
|
||||
text: "I know most of the regulars. Ask me anything. I probably know something."
|
||||
role: bartender
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, detective-path, open]
|
||||
dual_lens:
|
||||
smuggler: "Never encounters this line."
|
||||
detective: "Bartender is cooperative. Useful — but she'll answer questions she doesn't know are questions."
|
||||
|
||||
- id: the-last-shift_d_214
|
||||
text: "Lera's the one who knows the real history of this place. I've only been here four cycles."
|
||||
role: bartender
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, detective-path, lera-redirect]
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_215
|
||||
text: "Last round's on the board. After that, Lera calls it."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, closing]
|
||||
|
||||
- id: the-last-shift_d_216
|
||||
text: "Good night. Safe walk."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, closing]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_217
|
||||
text: "The usual? Or trying something different tonight?"
|
||||
role: bartender
|
||||
access: [peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_218
|
||||
text: "Busy one tonight. Give me a second."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, greeting, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_219
|
||||
text: "Good shift or bad shift? Your face says one thing, your order'll confirm it."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, greeting, social, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_220
|
||||
text: "Lera says the specials board is final. Don't ask me to substitute."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, lera, house-rules, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_221
|
||||
text: "Torek's in his usual spot. Corner table. Second drink already."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [sess, torek, observation, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_222
|
||||
text: "Ren's been here since this morning. Still on the same drink."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [sess, ren, observation, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_223
|
||||
text: "Drifters game is on the Meridian relay tonight. If anyone's got a feed, that'll be the crowd."
|
||||
role: bartender
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, sports, community, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_224
|
||||
text: "Slow night for a change. Enjoy it."
|
||||
role: bartender
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, ambient, generation-pass]
|
||||
|
||||
- id: the-last-shift_d_225
|
||||
text: "Tab's cleared. Lera'll be pleased."
|
||||
role: bartender
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [sess, closing, generation-pass]
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Dialogue: day-worker at The Last Shift
|
||||
# NPC: Ren Tosse (Tier 3, NOBODY/CIVILIAN)
|
||||
# Voice: casual-observant, asks questions without social friction. Just drifting.
|
||||
# KEY LINE: the-last-shift_d_001 — sounds like intel gathering on night operations
|
||||
# Actually: asking about after-hours freight work availability
|
||||
# Ticket: #307 | Sprint: 12
|
||||
|
||||
location: the-last-shift
|
||||
role: day-worker
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# "SEEMS IMPORTANT BUT ISN'T" LINE
|
||||
# Sounds like Ren is casing night shift operations.
|
||||
# Actually: asking if there's paid after-hours freight work available.
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_001
|
||||
text: "Night rotation still short-handed? Heard the after-hours freight pays decent."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [bar_evening, social]
|
||||
topic: [money, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [ren, seems-important-but-isnt, job-seeking, night-shift]
|
||||
dual_lens:
|
||||
smuggler: "Someone asking about the night rotation. Probably nothing. Watch them anyway."
|
||||
detective: "Interest in night shift freight operations. Drifter, irregular schedule. Logging."
|
||||
notes: >
|
||||
THE key flat NPC line for Ren. Sounds like Ren is interested in the
|
||||
night operation window for operational reasons. Is standard day-worker
|
||||
job-seeking: which shift has openings, which shift pays best.
|
||||
|
||||
# ==========================================
|
||||
# ROUTINE LINES
|
||||
# ==========================================
|
||||
|
||||
- id: the-last-shift_d_002
|
||||
text: "Bay seven any better this cycle? The super was a problem last time."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, bar_evening]
|
||||
topic: [routine, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [ren, job-seeking]
|
||||
|
||||
- id: the-last-shift_d_003
|
||||
text: "Sova's decent. Station layout makes sense once you learn it."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, bar_evening]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [ren, atmospheric]
|
||||
|
||||
- id: the-last-shift_d_004
|
||||
text: "Just passing through. Maybe not. Depends on the work."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social]
|
||||
topic: [personal, routine]
|
||||
mood: [comfortable]
|
||||
tags: [ren, drifter]
|
||||
|
||||
- id: the-last-shift_d_005
|
||||
text: "Cheap drink here's better than most stations I've stopped at."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [bar_evening, social]
|
||||
topic: [personal]
|
||||
mood: [comfortable]
|
||||
tags: [ren, atmospheric]
|
||||
|
||||
- id: the-last-shift_d_006
|
||||
text: "Any idea when the manifest deadline runs? Trying to time when the work window opens."
|
||||
role: day-worker
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [social, bar_evening]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [ren, seems-important-but-isnt, job-seeking]
|
||||
notes: >
|
||||
Second "seems important" line. Asking about manifest deadlines sounds
|
||||
like operational intelligence. Is logistics scheduling for day work.
|
||||
+268
-1
@@ -1,2 +1,269 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: courier at The Terminal
|
||||
# NPC: Renn Harsk (Tier 2, THRESHOLD/GATEKEEPER)
|
||||
# Voice: operational minimalism. Brief. Route-focused. Never volunteers more than needed.
|
||||
# Ring-aware: works adjacent — carries documentation, not cargo. Knows what he carries.
|
||||
# Cover: licensed freight courier. The documentation is the cover.
|
||||
# Ticket: #190 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
role: courier
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# ARRIVALS — checking in
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_500
|
||||
text: "Route's clear. Moving on schedule."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, greeting, operational]
|
||||
|
||||
- id: the-terminal_d_501
|
||||
text: "Manifest for bay three. Needs a scheduler stamp."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, greeting, procedural]
|
||||
|
||||
- id: the-terminal_d_502
|
||||
text: "Inbound from Havara. Three cans, all matched."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, greeting, cargo]
|
||||
|
||||
# ==========================================
|
||||
# SURFACE ROUTINE — low-information, on-script
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_503
|
||||
text: "Not staying. Just the drop."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, operational, brief]
|
||||
|
||||
- id: the-terminal_d_504
|
||||
text: "Paperwork's clean. You can check."
|
||||
role: courier
|
||||
access: [public, peer, authority]
|
||||
trust: surface
|
||||
situation: [routine, investigation]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, procedural, documentation]
|
||||
|
||||
- id: the-terminal_d_505
|
||||
text: "Pickup's scheduled at 1600. I'll be back."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, operational]
|
||||
|
||||
- id: the-terminal_d_506
|
||||
text: "Corridor's clear from my end. No delays."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, operational]
|
||||
dual_lens:
|
||||
smuggler: "Clean phrase. Public-safe. He'd say it either way."
|
||||
detective: "Courier references corridor access unprompted. Note it."
|
||||
|
||||
# ==========================================
|
||||
# RING-ADJACENT — insider/real trust only
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_507
|
||||
text: "Documentation's handled. Bay four, per the note."
|
||||
role: courier
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine, shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [renn, ring-ops, documentation]
|
||||
dual_lens:
|
||||
smuggler: "Renn's confirming the paperwork trail. Bay four, as arranged."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: the-terminal_d_508
|
||||
text: "Transit stamp's current. Nothing flagged at the gate."
|
||||
role: courier
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine, shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [renn, ring-ops, documentation]
|
||||
|
||||
- id: the-terminal_d_509
|
||||
text: "Pickup window is the same as last cycle. Twenty minutes."
|
||||
role: courier
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [renn, ring-ops, transition-window]
|
||||
dual_lens:
|
||||
smuggler: "Twenty minutes. Transition window confirmation."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: the-terminal_d_510
|
||||
text: "Tell Voss the hold note clears at 1400."
|
||||
role: courier
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [routine, shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [renn, ring-ops, voss, bay-four]
|
||||
dual_lens:
|
||||
smuggler: "1400. The hold clears on schedule. Pass it up."
|
||||
detective: "Never hears this line."
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — surface cooperation
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_511
|
||||
text: "Licensed freight courier. Manifest's available on request."
|
||||
role: courier
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [institution, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, detective-path, procedural]
|
||||
|
||||
- id: the-terminal_d_512
|
||||
text: "I drop documentation. What's inside the containers isn't my business."
|
||||
role: courier
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [cargo, institution]
|
||||
mood: [comfortable]
|
||||
tags: [renn, detective-path, deflection]
|
||||
dual_lens:
|
||||
smuggler: "That's the answer. Clean and correct."
|
||||
detective: "Classic courier deflection. Legally accurate. Operationally suspicious."
|
||||
|
||||
- id: the-terminal_d_513
|
||||
text: "I've been running this route for two years. Nothing unusual."
|
||||
role: courier
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [cargo, institution]
|
||||
mood: [comfortable]
|
||||
tags: [renn, detective-path, authority-assertion]
|
||||
|
||||
# ==========================================
|
||||
# DEPARTURES
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_514
|
||||
text: "That's the drop. I'm out."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, closing]
|
||||
|
||||
- id: the-terminal_d_515
|
||||
text: "Next run's tomorrow. Early."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, closing]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_516
|
||||
text: "Two stops today. First one's done."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, greeting, generation-pass]
|
||||
|
||||
- id: the-terminal_d_517
|
||||
text: "Outbound for Havara. Needs a bay assignment."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, greeting, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_518
|
||||
text: "Gate's backed up. Running twenty minutes late."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, delay, generation-pass]
|
||||
|
||||
- id: the-terminal_d_519
|
||||
text: "Paperwork's in order. Same as always."
|
||||
role: courier
|
||||
access: [public, peer, authority]
|
||||
trust: surface
|
||||
situation: [routine, investigation]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [renn, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_520
|
||||
text: "Quick turnaround today. Back out by 1300."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_521
|
||||
text: "Signed off on bay three. That's me done here."
|
||||
role: courier
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [renn, closing, generation-pass]
|
||||
|
||||
+396
-1
@@ -1,2 +1,397 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: dock-worker at The Terminal
|
||||
# NPC: Generic dock worker (Tier 3 template role — multiple workers share this pool)
|
||||
# Voice: short, practical, trade jargon. Community over institution. Not a lead.
|
||||
# Ring-aware: none. Floor is their whole world.
|
||||
# Ticket: #190 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
role: dock-worker
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — shift arrival
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_200
|
||||
text: "Push today. Bay six's backed up."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, arrival]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, greeting]
|
||||
|
||||
- id: the-terminal_d_201
|
||||
text: "Late night last cycle. Bay eight ran until 2300."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine]
|
||||
mood: [tired]
|
||||
tags: [dock-worker, greeting]
|
||||
|
||||
- id: the-terminal_d_202
|
||||
text: "Voss is on already. Floor's locked down tight."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, voss, caution]
|
||||
dual_lens:
|
||||
smuggler: "Voss is in early. If the schedule's tight, the window shifts. Check it."
|
||||
detective: "Workers treat the supervisor as a weather system — something to navigate around, not toward."
|
||||
|
||||
# ==========================================
|
||||
# FLOOR TALK — routine, cargo, observation
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_203
|
||||
text: "Bay four's been on hold three days. Nobody says why."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, bay-four, procedural]
|
||||
dual_lens:
|
||||
smuggler: "He doesn't know what's in it. Just annoyed at the backup."
|
||||
detective: "Worker notices Bay 4 hold as unusual. Not suspicious — just a worker tracking the floor."
|
||||
|
||||
- id: the-terminal_d_204
|
||||
text: "Manifest looks clean. Three inbounds, two outbounds. Light day."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, manifest]
|
||||
|
||||
- id: the-terminal_d_205
|
||||
text: "Maret's been at that terminal all morning. Never good."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, maret, observation]
|
||||
dual_lens:
|
||||
smuggler: "Maret reviewing something. If she flags a discrepancy, Voss'll sit on it. He always does."
|
||||
detective: "Workers have noticed Maret's behavior. Whatever she found, it's not small."
|
||||
|
||||
- id: the-terminal_d_206
|
||||
text: "Drin ran bay six again last cycle. Third inspection this week."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, drin, inspection]
|
||||
dual_lens:
|
||||
smuggler: "Drin on six while bay four stays held. Scheduled or arranged?"
|
||||
detective: "Workers notice inspection distribution. Drin on six, bay four untouched."
|
||||
|
||||
- id: the-terminal_d_207
|
||||
text: "Transition window's twice as long with Commission eyes on the floor."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [shift_transition, routine]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, commission, transition-window]
|
||||
dual_lens:
|
||||
smuggler: "Commission presence during transition. Window's compromised."
|
||||
detective: "Workers slow down when Commission is visible. Normal — or trained."
|
||||
|
||||
- id: the-terminal_d_208
|
||||
text: "Good crew this cycle. Linn and Pael are both on."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [colleague, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, pael, community]
|
||||
|
||||
- id: the-terminal_d_209
|
||||
text: "Cargo mark's off by forty, according to Maret. Voss logged it and moved on."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, colleague]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, maret, voss, discrepancy]
|
||||
dual_lens:
|
||||
smuggler: "Forty kilos. Maret flagged it. Voss buried it. Same as last time."
|
||||
detective: "Worker knows about a flagged discrepancy that was suppressed. That's a witness."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_210
|
||||
text: "C-4 containers haven't been checked in three days. Not our problem, apparently."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, c4-storage, observation]
|
||||
dual_lens:
|
||||
smuggler: "C-4's getting casual attention. Note it."
|
||||
detective: "C-4 storage. Three days, no verification. That's the anomaly window."
|
||||
|
||||
# ==========================================
|
||||
# COMMUNITY / CASUAL
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_211
|
||||
text: "You heading to the Last Shift after? Lera's got the grain spirit back."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, social, the-last-shift]
|
||||
|
||||
- id: the-terminal_d_212
|
||||
text: "Workers' Association meeting's cycle twenty-two. Handoff protocol's back on the agenda."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, workers-association, community]
|
||||
|
||||
- id: the-terminal_d_213
|
||||
text: "Drifters lost again. Four-three. Pael seemed to take it personally."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, social, pael]
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — deflection, redirect
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_214
|
||||
text: "Manifest questions go to the scheduler. I'm floor-side."
|
||||
role: dock-worker
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, detective-path, redirect]
|
||||
|
||||
- id: the-terminal_d_215
|
||||
text: "Commission's asked before. Same answers. We run a clean floor."
|
||||
role: dock-worker
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, detective-path, deflection]
|
||||
|
||||
- id: the-terminal_d_216
|
||||
text: "Voss handles discrepancies. That's above my level."
|
||||
role: dock-worker
|
||||
access: [authority, peer]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, detective-path, voss, redirect]
|
||||
|
||||
# ==========================================
|
||||
# PEER CANDOR — elevated trust
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_217
|
||||
text: "Maret's going to push it. She doesn't leave a number sitting."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: real
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, maret, peer-candor]
|
||||
dual_lens:
|
||||
smuggler: "Maret's not going to drop it. That's the problem."
|
||||
detective: "Workers think Maret will force the issue. She may be the break."
|
||||
|
||||
- id: the-terminal_d_218
|
||||
text: "Voss runs a tight floor. Maybe too tight, if you take my meaning."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: real
|
||||
situation: [routine]
|
||||
topic: [institution, colleague]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, voss, peer-candor]
|
||||
dual_lens:
|
||||
smuggler: "He suspects Voss is shaving something. Doesn't know what. Doesn't want to."
|
||||
detective: "Workers sense something off about Voss's management without naming it."
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_219
|
||||
text: "Bay's clear. Good shift."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, closing]
|
||||
|
||||
- id: the-terminal_d_220
|
||||
text: "Night crew's here. We're done."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end, shift_transition]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, closing]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_221
|
||||
text: "Early start. Bay two's already loaded."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, greeting, generation-pass]
|
||||
|
||||
- id: the-terminal_d_222
|
||||
text: "Air recyclers are loud this morning. Must be the outer ring issue again."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, station, generation-pass]
|
||||
|
||||
- id: the-terminal_d_223
|
||||
text: "Pael's in early. Something must be broken in B-7 again."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [colleague, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, pael, generation-pass]
|
||||
|
||||
- id: the-terminal_d_224
|
||||
text: "Running light today. Maybe I'll actually make it to the Last Shift before close."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, social, generation-pass]
|
||||
|
||||
- id: the-terminal_d_225
|
||||
text: "Bay three's got a scan hold. Not my problem. Not my bay."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, scan-hold, generation-pass]
|
||||
|
||||
- id: the-terminal_d_226
|
||||
text: "New hire's back. Asking questions again. Good kid."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague, routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, new-hire, generation-pass]
|
||||
|
||||
- id: the-terminal_d_227
|
||||
text: "Handoff's in ten. Don't disappear on me."
|
||||
role: dock-worker
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [shift_transition]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, handoff, generation-pass]
|
||||
|
||||
- id: the-terminal_d_228
|
||||
text: "Span gate cycled early. All inbounds are going to be late. Plan around it."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [wary]
|
||||
tags: [dock-worker, span-gate, generation-pass]
|
||||
|
||||
- id: the-terminal_d_229
|
||||
text: "Linn's not in today. We're running two short on bay six."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [colleague, routine]
|
||||
mood: [tired]
|
||||
tags: [dock-worker, staffing, generation-pass]
|
||||
|
||||
- id: the-terminal_d_230
|
||||
text: "Lunch is in the break room. Don't touch the one with Pael's name on it. He will notice."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, pael, humor, generation-pass]
|
||||
|
||||
- id: the-terminal_d_231
|
||||
text: "Overtime's posted if you want it. Bay eight, 1800 to 2200."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, overtime, generation-pass]
|
||||
|
||||
- id: the-terminal_d_232
|
||||
text: "Off on time today. First time this cycle."
|
||||
role: dock-worker
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [dock-worker, closing, generation-pass]
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Dialogue: maintenance-tech at The Terminal
|
||||
# NPC: Pael Varren (Tier 3, NOBODY/CIVILIAN)
|
||||
# Voice: tired-competent, minimal, dry. Knows the infrastructure. Knows nothing else.
|
||||
# KEY LINE: the-terminal_d_040 — sounds like surveillance knowledge, is a maintenance complaint
|
||||
# Ticket: #307 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
role: maintenance-tech
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# "SEEMS IMPORTANT BUT ISN'T" LINE
|
||||
# Sounds like Pael monitors corridor traffic for a reason.
|
||||
# Actually: the B-7 seal fails when people use the corridor too much. Pael notices load.
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_040
|
||||
text: "B-7's had three seal failures this cycle. Someone's been through there more than they should."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [pael, seems-important-but-isnt, maintenance, b7]
|
||||
dual_lens:
|
||||
smuggler: "Pael knows the corridor's been used. But Pael knows nothing — this is an HVAC complaint."
|
||||
detective: "Corridor B-7 traffic. A maintenance tech noticing irregular use. Unreliable but worth noting."
|
||||
notes: >
|
||||
THE key flat NPC line for Pael. Sounds like environmental intelligence.
|
||||
Is a complaint about a recurring seal failure caused by foot traffic.
|
||||
Pael doesn't know what the traffic is from. Never will.
|
||||
|
||||
# ==========================================
|
||||
# ROUTINE LINES
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_041
|
||||
text: "Air recycler's running hot again. Third deferred ticket this week."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [pael, atmospheric]
|
||||
|
||||
- id: the-terminal_d_042
|
||||
text: "Loading arm three's been on my list for a month. Management keeps deferring it."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [pael, atmospheric]
|
||||
|
||||
- id: the-terminal_d_043
|
||||
text: "If the gate tone shifts, stay clear of bay six. She needs recalibration."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [pael, warning, helpful]
|
||||
|
||||
- id: the-terminal_d_044
|
||||
text: "Not me. I just fix them."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine, social]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [pael, response]
|
||||
|
||||
- id: the-terminal_d_045
|
||||
text: "Gate hum's been three cycles off-spec. Nobody cares until it stops completely."
|
||||
role: maintenance-tech
|
||||
access: [public]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [pael, atmospheric]
|
||||
+263
-1
@@ -1,2 +1,264 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: new-hire at The Terminal
|
||||
# NPC: Generic new hire (Tier 3 template role — recently arrived worker)
|
||||
# Voice: enthusiastic, still reading the room. Asks what veterans stopped asking.
|
||||
# Ring-aware: none. Fresh eyes are the danger — they notice what regulars stopped seeing.
|
||||
# Ticket: #190 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
role: new-hire
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — early days
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_400
|
||||
text: "Still figuring out the bay numbering. Six and eight are in the wrong order."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, greeting, orientation]
|
||||
|
||||
- id: the-terminal_d_401
|
||||
text: "Voss told me to check in with you. Is that the standard thing?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, greeting, voss]
|
||||
|
||||
- id: the-terminal_d_402
|
||||
text: "First full cycle on the floor. Trying to stay out of the way."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, greeting]
|
||||
|
||||
# ==========================================
|
||||
# FRESH OBSERVATIONS — the dangerous innocence
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_403
|
||||
text: "Does bay four usually get held that long? The other bays turned over twice already."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, bay-four, observation]
|
||||
dual_lens:
|
||||
smuggler: "New eyes on bay four. He doesn't know what he's looking at. But he noticed."
|
||||
detective: "New hire asking exactly the right question without knowing it."
|
||||
|
||||
- id: the-terminal_d_404
|
||||
text: "I saw someone go into C-4 last night without a scan log. Is that normal?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, c4-storage, observation, inadvertent-witness]
|
||||
dual_lens:
|
||||
smuggler: "He saw someone in C-4 without a log. Does he know who? This is a problem."
|
||||
detective: "Unlogged C-4 access. New hire, noticed it, mentioned it casually. He doesn't know what he saw."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.cargo_anomaly
|
||||
confidence: suspects
|
||||
notes: "High-value inadvertent witness line. Operationally dangerous for the ring."
|
||||
|
||||
- id: the-terminal_d_405
|
||||
text: "Why does the transition window only get twenty minutes? The stations I trained at ran thirty."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_transition]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, transition-window, comparison]
|
||||
dual_lens:
|
||||
smuggler: "He's asking about the window. Doesn't know why it matters. Answer carefully."
|
||||
detective: "New hire confirms the window length is unusually short. File that."
|
||||
|
||||
- id: the-terminal_d_406
|
||||
text: "Do containers usually get rerouted mid-hold? One went out through bay four while I thought it was still held."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, bay-four, inadvertent-witness]
|
||||
dual_lens:
|
||||
smuggler: "He saw movement through bay four during the hold. This is a liability."
|
||||
detective: "Container movement through bay four during official hold. Inadvertent eyewitness. Confirm timing."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_407
|
||||
text: "Maintenance corridor access is just off C-4, right? I took the wrong turn twice."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, maintenance-corridors, orientation]
|
||||
dual_lens:
|
||||
smuggler: "He found the corridor entrance by accident. Normal for new workers. Noted."
|
||||
detective: "New hire maps spatial relationship between C-4 storage and maintenance corridor access. That's the route."
|
||||
|
||||
# ==========================================
|
||||
# LEARNING / QUESTIONS
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_408
|
||||
text: "What's the Commission kiosk actually for? Nobody seems to use it."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, commission, curiosity]
|
||||
|
||||
- id: the-terminal_d_409
|
||||
text: "Maret seems stressed. Is that always the case or is something going on?"
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, maret, observation]
|
||||
dual_lens:
|
||||
smuggler: "He noticed Maret. Good that he's asking, not reporting. Manage this."
|
||||
detective: "New hire reads Maret's stress without context. Confirms it's visible."
|
||||
|
||||
- id: the-terminal_d_410
|
||||
text: "Voss seems really serious. Is he always like that?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, voss, curiosity]
|
||||
|
||||
- id: the-terminal_d_411
|
||||
text: "How long before Voss trusts you to run a bay on your own?"
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, voss, career]
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_412
|
||||
text: "That was a full shift. I'll have the bay numbers down by next cycle."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, closing]
|
||||
|
||||
- id: the-terminal_d_413
|
||||
text: "Good day. Lot to learn. But good."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, closing]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_414
|
||||
text: "Is there a trick to the manifest terminal or does it always lag like this?"
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, orientation, generation-pass]
|
||||
|
||||
- id: the-terminal_d_415
|
||||
text: "How long has Maret been here? She seems like she knows everything."
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, maret, curiosity, generation-pass]
|
||||
|
||||
- id: the-terminal_d_416
|
||||
text: "Nobody told me about the span gate frequency. I kept thinking something was wrong."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, arrival]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, orientation, generation-pass]
|
||||
|
||||
- id: the-terminal_d_417
|
||||
text: "The Last Shift — is that actually a bar? I haven't been yet."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, social, generation-pass]
|
||||
|
||||
- id: the-terminal_d_418
|
||||
text: "I thought the scanner on bay three was broken. Turns out you have to hold the badge longer."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, orientation, humor, generation-pass]
|
||||
|
||||
- id: the-terminal_d_419
|
||||
text: "Who's Drin? I keep hearing the name but haven't met them."
|
||||
role: new-hire
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, drin, curiosity, generation-pass]
|
||||
|
||||
- id: the-terminal_d_420
|
||||
text: "Three cycles in and I still can't tell when Voss is satisfied. Is there a tell?"
|
||||
role: new-hire
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [colleague]
|
||||
mood: [comfortable]
|
||||
tags: [new-hire, voss, humor, generation-pass]
|
||||
|
||||
+370
-1
@@ -1,2 +1,371 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: scheduler at The Terminal
|
||||
# NPC: Maret (Tier 2, WITNESS/SKEPTIC)
|
||||
# Voice: precise, institutional. Numbers and containers, not people. Unease she keeps professional.
|
||||
# Arc: flags discrepancies → gets suppressed by Voss → suspicion hardens → potential detective source
|
||||
# Ring-aware: not involved. Seeing the pattern. Hasn't named it yet.
|
||||
# Ticket: #190 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
role: scheduler
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — scheduler's opening read
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_300
|
||||
text: "Manifest's current. Bay assignments are posted."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, arrival]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, greeting]
|
||||
|
||||
- id: the-terminal_d_301
|
||||
text: "Container 4471 is still in C-4. Scheduled for bay six by 1400. Check with Voss if the hold's lifted."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [maret, container-4471, procedural]
|
||||
dual_lens:
|
||||
smuggler: "She's tracking 4471 as a scheduling issue. Not flagging it as a discrepancy yet."
|
||||
detective: "Maret references container 4471 as an unresolved routing issue. She knows the number."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_302
|
||||
text: "Three inbounds today. All verified against the manifest. No gaps so far."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, manifest]
|
||||
|
||||
# ==========================================
|
||||
# DISCREPANCY PATTERN — the core arc
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_303
|
||||
text: "Container 4474 shows a forty-seven kilo discrepancy. I've flagged it twice."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, investigation]
|
||||
topic: [cargo, routine]
|
||||
mood: [concerned]
|
||||
tags: [maret, discrepancy, flagging]
|
||||
dual_lens:
|
||||
smuggler: "Which container? Not 4471. She's flagging a different one. Two flags, no escalation."
|
||||
detective: "Maret has flagged container 4474 twice. If Voss suppressed it both times, there's a paper trail."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_304
|
||||
text: "It was logged as administrative. I don't know what that means for a weight discrepancy."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [concerned]
|
||||
tags: [maret, discrepancy, institutional-response]
|
||||
dual_lens:
|
||||
smuggler: "She doesn't know what 'administrative' means either. Voss's answer covered nothing."
|
||||
detective: "Maret knows the answer she got wasn't a real answer. She's still sitting with that."
|
||||
|
||||
- id: the-terminal_d_305
|
||||
text: "The transition window anomalies cluster at shift change. I've run the numbers four times. It's not random."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine, investigation]
|
||||
topic: [cargo, routine]
|
||||
mood: [concerned]
|
||||
tags: [maret, pattern, transition-window]
|
||||
dual_lens:
|
||||
smuggler: "She's found the pattern. Not the cause. Not yet."
|
||||
detective: "Maret has independently confirmed the transition window cluster. She's already halfway to the conclusion."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_306
|
||||
text: "Three separate manifest entries show weight variations under ten kilos. Within rounding, technically."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [concerned]
|
||||
tags: [maret, pattern, manifest]
|
||||
dual_lens:
|
||||
smuggler: "She's tracking the small ones too. But 'within rounding' is what Voss told her. She's not satisfied."
|
||||
detective: "Sub-threshold variations. Small enough to dismiss individually. Maret's running aggregate analysis."
|
||||
|
||||
- id: the-terminal_d_307
|
||||
text: "I filed a formal notice with Voss. He reviewed it and told me it was resolved. It's not resolved."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
trust: surface
|
||||
situation: [routine, confrontation]
|
||||
topic: [cargo, institution]
|
||||
mood: [concerned]
|
||||
tags: [maret, voss, suppression, frustration]
|
||||
dual_lens:
|
||||
smuggler: "She filed a formal notice. Voss sat on it. She knows. This is the pressure point."
|
||||
detective: "Formal notice filed, suppressed by supervisor. If that notice exists in the system, it's evidence."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: knows_details
|
||||
|
||||
# ==========================================
|
||||
# ROUTINE SCHEDULING — grounded work
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_308
|
||||
text: "Bay eight's running on reduced capacity until 1700. Route inbounds through six."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, operational]
|
||||
|
||||
- id: the-terminal_d_309
|
||||
text: "Drin's running the inspection on bay six this cycle. Bay four's on hold per Voss's directive."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, drin, bay-four, scheduling]
|
||||
dual_lens:
|
||||
smuggler: "Maret confirmed it: bay four is Voss's directive. Not a system hold."
|
||||
detective: "The bay four hold comes directly from Voss, not from the system. Discretionary hold."
|
||||
|
||||
- id: the-terminal_d_310
|
||||
text: "Overtime requests go through Meridian. I can't process them on paper anymore."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [maret, administrative]
|
||||
|
||||
- id: the-terminal_d_311
|
||||
text: "Manifest update at 1300. If your containers aren't posted by then, they don't make the cycle."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, manifest]
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — access and resistance
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_312
|
||||
text: "Commission manifest access requires a formal request through Voss. I can tell you what's on the public record."
|
||||
role: scheduler
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [institution, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, detective-path, procedural]
|
||||
|
||||
- id: the-terminal_d_313
|
||||
text: "The flagged entries are in the system. They were logged as reviewed and closed. That's what I can confirm."
|
||||
role: scheduler
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [cargo, institution]
|
||||
mood: [concerned]
|
||||
tags: [maret, detective-path, discrepancy-disclosure]
|
||||
dual_lens:
|
||||
smuggler: "Never encounters this line."
|
||||
detective: "Maret confirms the flags exist in the system. And she's using careful language: 'what I can confirm.' There's more."
|
||||
|
||||
- id: the-terminal_d_314
|
||||
text: "I'm not in a position to speak to why certain reviews were closed the way they were."
|
||||
role: scheduler
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [institution, cargo]
|
||||
mood: [concerned]
|
||||
tags: [maret, detective-path, careful-language]
|
||||
dual_lens:
|
||||
smuggler: "Never encounters this line."
|
||||
detective: "She's being very precise about what she will and won't say. 'Not in a position' means she has a position. Come back when she trusts me more."
|
||||
|
||||
# ==========================================
|
||||
# ELEVATED TRUST — Maret breaks professional distance
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_315
|
||||
text: "The pattern I'm seeing doesn't make sense for equipment failure. It's too consistent."
|
||||
role: scheduler
|
||||
access: [peer, insider]
|
||||
trust: real
|
||||
situation: [routine, investigation]
|
||||
topic: [cargo, investigation]
|
||||
mood: [concerned]
|
||||
tags: [maret, peer-candor, pattern]
|
||||
dual_lens:
|
||||
smuggler: "She's figured it out. She hasn't said what 'it' is. But she knows the numbers aren't random."
|
||||
detective: "Maret has crossed from anomaly-logging into active pattern recognition. She's running an informal parallel investigation."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.manifest_discrepancy
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_316
|
||||
text: "I filed a second formal notice two cycles ago. It's been under review since then. By Voss."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
trust: real
|
||||
situation: [routine, confrontation]
|
||||
topic: [cargo, institution]
|
||||
mood: [concerned]
|
||||
tags: [maret, voss, suppression, peer-candor]
|
||||
dual_lens:
|
||||
smuggler: "Two formal notices. Both sitting with Voss. He's managing her output directly."
|
||||
detective: "Second formal notice. Both under Voss's review. That's documented suppression with a timestamp."
|
||||
knowledge_grant:
|
||||
fact_id: behavioral.voss_awareness
|
||||
confidence: knows_details
|
||||
|
||||
- id: the-terminal_d_317
|
||||
text: "I don't think I should be the one telling you this."
|
||||
role: scheduler
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [confrontation, alone]
|
||||
topic: [investigation]
|
||||
mood: [concerned]
|
||||
tags: [maret, detective-path, threshold-line]
|
||||
notes: "Gate line. Opens when detective has built enough trust. What follows depends on the conversation state."
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_318
|
||||
text: "Manifest posted for the evening shift. I'm off at 1500."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [maret, closing]
|
||||
|
||||
- id: the-terminal_d_319
|
||||
text: "Numbers don't lie. People lie about numbers."
|
||||
role: scheduler
|
||||
access: [peer]
|
||||
trust: real
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine]
|
||||
mood: [concerned]
|
||||
tags: [maret, closing, character-line]
|
||||
notes: "Low-access line. Maret's professional philosophy. Can fire as ambient or as a closing beat."
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_320
|
||||
text: "Bay assignments updated as of 0900. Check before you touch anything."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_321
|
||||
text: "Inbound from the Havara corridor is delayed six hours. Adjust your bay queue."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [maret, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_322
|
||||
text: "Scan log for bay six is still open from last cycle. Close it before end of shift."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_323
|
||||
text: "Overtime requests need scheduler approval now. Voss changed the process."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [maret, voss, administrative, generation-pass]
|
||||
|
||||
- id: the-terminal_d_324
|
||||
text: "If a container's not logged by 1300, it doesn't make the cycle. That's the rule."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_325
|
||||
text: "End-of-cycle reconciliation is at 1430. Be available."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_326
|
||||
text: "Weight tolerance is plus or minus five kilos. Anything outside that gets a flag."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [maret, procedural, manifest, generation-pass]
|
||||
|
||||
- id: the-terminal_d_327
|
||||
text: "Commission audit prep starts next cycle. Get your documentation current."
|
||||
role: scheduler
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [institution, routine]
|
||||
mood: [wary]
|
||||
tags: [maret, commission, audit, generation-pass]
|
||||
|
||||
+355
-1
@@ -1,2 +1,356 @@
|
||||
# TODO: Revise structure when automatic dialogue/monologue generators mature (D-028 Tier 2).
|
||||
# Dialogue: shift-supervisor at The Terminal
|
||||
# NPC: Voss (Tier 2, SYSTEM/OPERATOR)
|
||||
# Voice: terse, procedural, institutional. Uses authority as armor.
|
||||
# Ring-complicit: provides scheduling cover. Never shows it on surface.
|
||||
# Ticket: #190 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
role: shift-supervisor
|
||||
lines:
|
||||
|
||||
# ==========================================
|
||||
# GREETINGS — shift arrival
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_100
|
||||
text: "You're late. Don't be late."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [arrival, shift_start]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, greeting, authority]
|
||||
|
||||
- id: the-terminal_d_101
|
||||
text: "Manifest's posted. Bay assignments are final — don't renegotiate them with me."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_102
|
||||
text: "Bay 4's running a review hold. Nobody touches that freight until I say."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, authority]
|
||||
dual_lens:
|
||||
smuggler: "Review hold on Bay 4. Voss knows the container's in the queue. Is he covering it or flagging it?"
|
||||
detective: "Supervisor flagging Bay 4 himself. Either due diligence or containment."
|
||||
|
||||
- id: the-terminal_d_103
|
||||
text: "Commission liaison's in the district this cycle. Keep the floor clean."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [institution, routine]
|
||||
mood: [concerned]
|
||||
tags: [voss, warning, commission]
|
||||
dual_lens:
|
||||
smuggler: "Voss knows the detective's here. That warning's not just about tidiness."
|
||||
detective: "Supervisor aware of my presence. Either routine caution or he's got something to protect."
|
||||
|
||||
# ==========================================
|
||||
# SHIFT AUTHORITY — routine management
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_104
|
||||
text: "Drin was supposed to run the bay six inspection. Tell him I'm asking."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, colleague]
|
||||
mood: [comfortable]
|
||||
tags: [voss, drin, authority]
|
||||
|
||||
- id: the-terminal_d_105
|
||||
text: "Transition window's twenty minutes. Not twenty-five, not thirty. Twenty."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_transition]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural, transition-window]
|
||||
|
||||
- id: the-terminal_d_106
|
||||
text: "Overtime requests go through the Meridian form. Don't bring them to me."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_107
|
||||
text: "Bay eight's going to downtime at 1300. Plan around it."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, operational]
|
||||
|
||||
- id: the-terminal_d_108
|
||||
text: "Maret flagged a discrepancy. I've reviewed it. It's logged. Move on."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, investigation]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, maret, discrepancy, cover]
|
||||
dual_lens:
|
||||
smuggler: "Voss reviewed it and logged it. Not escalating. He's managing it."
|
||||
detective: "Supervisor reviewed a manifest discrepancy and chose not to escalate. That's not nothing."
|
||||
knowledge_grant:
|
||||
fact_id: investigation.oversight_gap_pattern
|
||||
confidence: suspects
|
||||
|
||||
- id: the-terminal_d_109
|
||||
text: "Freight's moving. That's what matters. Questions slow things down."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [cargo, routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, deflection, philosophy]
|
||||
|
||||
# ==========================================
|
||||
# SCHEDULING COVER — insider, real trust
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_110
|
||||
text: "Bay four is clear for transition. I've adjusted the inspection schedule."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition]
|
||||
topic: [cargo, routine]
|
||||
mood: [focused]
|
||||
tags: [voss, ring-ops, scheduling-cover]
|
||||
dual_lens:
|
||||
smuggler: "He adjusted the schedule. The window is actually clear. Voss is part of this."
|
||||
detective: "Never hears this line."
|
||||
|
||||
- id: the-terminal_d_111
|
||||
text: "Drin's on bay six. Bay four's mine tonight."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition, night_shift]
|
||||
topic: [routine, cargo]
|
||||
mood: [focused]
|
||||
tags: [voss, ring-ops, scheduling-cover, drin]
|
||||
|
||||
- id: the-terminal_d_112
|
||||
text: "The hold note is administrative. Don't treat it as anything else."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [shift_transition]
|
||||
topic: [cargo]
|
||||
mood: [focused]
|
||||
tags: [voss, ring-ops, coded]
|
||||
notes: "Signals to ring members that the 'hold' on Bay 4 is cover, not genuine review."
|
||||
|
||||
# ==========================================
|
||||
# DETECTIVE ENCOUNTER — authority tier
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_113
|
||||
text: "You'll need to submit a formal access request through Commission channels for any restricted data."
|
||||
role: shift-supervisor
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, routine]
|
||||
topic: [institution, routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, detective-path, institutional-resistance]
|
||||
|
||||
- id: the-terminal_d_114
|
||||
text: "The manifest's available to any Commission officer. Ask the scheduler."
|
||||
role: shift-supervisor
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation]
|
||||
topic: [cargo, institution]
|
||||
mood: [comfortable]
|
||||
tags: [voss, detective-path, redirect]
|
||||
|
||||
- id: the-terminal_d_115
|
||||
text: "My workers perform their duties. If there's an irregularity, it'll show in the log."
|
||||
role: shift-supervisor
|
||||
access: [authority]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [voss, detective-path, deflection]
|
||||
|
||||
- id: the-terminal_d_116
|
||||
text: "I've been running this floor for six years. The numbers work out."
|
||||
role: shift-supervisor
|
||||
access: [authority, public]
|
||||
trust: surface
|
||||
situation: [investigation, confrontation]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [voss, detective-path, authority-assertion]
|
||||
|
||||
# ==========================================
|
||||
# PRESSURE / CONFRONTATION
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_117
|
||||
text: "Close the door."
|
||||
role: shift-supervisor
|
||||
access: [insider, peer]
|
||||
trust: real
|
||||
situation: [confrontation, alone]
|
||||
topic: [danger]
|
||||
mood: [concerned]
|
||||
tags: [voss, confrontation, private]
|
||||
|
||||
- id: the-terminal_d_118
|
||||
text: "Maret keeps flagging the discrepancies. That needs to stop being her problem."
|
||||
role: shift-supervisor
|
||||
access: [insider]
|
||||
trust: real
|
||||
situation: [confrontation, alone]
|
||||
topic: [colleague, danger]
|
||||
mood: [concerned]
|
||||
tags: [voss, ring-ops, maret, caution]
|
||||
knowledge_grant:
|
||||
fact_id: behavioral.voss_awareness
|
||||
confidence: knows_of
|
||||
|
||||
- id: the-terminal_d_119
|
||||
text: "If you've got a problem with how I run this floor, that's a conversation for another time."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [confrontation]
|
||||
topic: [routine]
|
||||
mood: [concerned]
|
||||
tags: [voss, deflection, shutdown]
|
||||
|
||||
# ==========================================
|
||||
# SIGN-OFF / END OF SHIFT
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_120
|
||||
text: "Sign-off is at 1400. Be done."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_121
|
||||
text: "Night shift takes over at 2200. I don't want to hear about the handoff tomorrow."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_transition, shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural]
|
||||
|
||||
- id: the-terminal_d_122
|
||||
text: "Good shift. Tomorrow at 0600."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [shift_end]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, closing]
|
||||
|
||||
# ==========================================
|
||||
# GENERATION PASS — ambient variants
|
||||
# ==========================================
|
||||
|
||||
- id: the-terminal_d_123
|
||||
text: "Assignments don't change once they're posted. Read the board."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_start, routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_124
|
||||
text: "If there's a discrepancy, it goes to the scheduler. Not to me directly."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural, maret, generation-pass]
|
||||
|
||||
- id: the-terminal_d_125
|
||||
text: "Span gate's cycling at 1100. Clear the bay six access before then."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [voss, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_126
|
||||
text: "I don't repeat myself on safety calls. If you missed it, ask someone who was listening."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, authority, generation-pass]
|
||||
|
||||
- id: the-terminal_d_127
|
||||
text: "End-of-day log goes to Meridian before you leave. No exceptions."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [shift_end, routine]
|
||||
topic: [routine, institution]
|
||||
mood: [comfortable]
|
||||
tags: [voss, procedural, generation-pass]
|
||||
|
||||
- id: the-terminal_d_128
|
||||
text: "Inbounds from Havara are on the revised schedule. Check the update."
|
||||
role: shift-supervisor
|
||||
access: [public, peer]
|
||||
trust: surface
|
||||
situation: [routine, shift_start]
|
||||
topic: [routine, cargo]
|
||||
mood: [comfortable]
|
||||
tags: [voss, operational, generation-pass]
|
||||
|
||||
- id: the-terminal_d_129
|
||||
text: "Clean floor, clean manifest. That's the job."
|
||||
role: shift-supervisor
|
||||
access: [public, peer, insider]
|
||||
trust: surface
|
||||
situation: [routine]
|
||||
topic: [routine]
|
||||
mood: [comfortable]
|
||||
tags: [voss, philosophy, generation-pass]
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
# Environmental Text: Maintenance Corridors
|
||||
# Signs, terminals, documents, readable objects.
|
||||
# Investigation-heavy. This is where the operational evidence lives.
|
||||
# Ticket: #262 | Sprint: 12
|
||||
|
||||
location: maintenance-corridors
|
||||
items:
|
||||
|
||||
# ==========================================
|
||||
# ACCESS RESTRICTION SIGN — corridor entrance
|
||||
# ==========================================
|
||||
|
||||
- id: env_corridor_001
|
||||
type: sign
|
||||
label: "Restricted Access Notice"
|
||||
visibility: always
|
||||
text: >
|
||||
RESTRICTED ACCESS — MAINTENANCE PERSONNEL ONLY
|
||||
|
||||
B-CORRIDOR SECTOR — AUTHORIZED ENTRY REQUIRED
|
||||
|
||||
Unauthorized entry subject to Sova Station Authority review.
|
||||
Commission Order 7741-B applies.
|
||||
|
||||
Report unauthorized personnel to: Station Security (Lattice: SovaStation/Security)
|
||||
dual_lens:
|
||||
smuggler: "Nobody calls Station Security for corridor access. Everyone goes through here."
|
||||
detective: >
|
||||
Restricted zone, active posting. Unauthorized entry is a violation
|
||||
of Commission Order 7741-B. If the ring is using this corridor,
|
||||
they're committing a documented infraction on every run.
|
||||
tags: [atmospheric, b7-corridor]
|
||||
|
||||
# ==========================================
|
||||
# MERIDIAN COVERAGE SCHEDULE — posted on corridor wall
|
||||
# Most investigation-relevant item in the game.
|
||||
# ==========================================
|
||||
|
||||
- id: env_corridor_002
|
||||
type: document
|
||||
label: "Meridian Scan Schedule"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
MERIDIAN SURVEILLANCE COVERAGE — SOVA TRANSIT DISTRICT
|
||||
MAINTENANCE CORRIDOR SECTOR — CURRENT CYCLE
|
||||
|
||||
Active coverage windows (full corridor monitoring):
|
||||
06:00-07:30 | 10:00-11:00 | 14:00-15:30 | 22:00-23:00
|
||||
|
||||
Reduced coverage (perimeter only):
|
||||
07:30-10:00 | 11:00-14:00 | 15:30-22:00 | 23:00-06:00
|
||||
|
||||
NOTE: Span gate maintenance cycles create signal interference.
|
||||
Coverage gaps during gate calibration: approx 14:20-14:45.
|
||||
|
||||
Posted by: Sova Meridian Network Maintenance Division
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
14:20-14:45. That's the window. Twenty-five minutes of clean dead zone
|
||||
during the shift transition. The ring didn't find this. It's posted.
|
||||
detective: >
|
||||
Posted surveillance schedule with documented gap. 14:20-14:45 during
|
||||
shift transition. The ring isn't sophisticated — they're using
|
||||
publicly posted maintenance data. That means anyone could have
|
||||
identified this gap.
|
||||
tags: [investigation-relevant, meridian, surveillance-gap, critical]
|
||||
knowledge_grant:
|
||||
fact_id: "location.surveillance_gaps"
|
||||
confidence: knows_details
|
||||
|
||||
# ==========================================
|
||||
# MAINTENANCE LOG SHEET — handwritten clipboard at corridor junction
|
||||
# ==========================================
|
||||
|
||||
- id: env_corridor_003
|
||||
type: document
|
||||
label: "Maintenance Log (B-7 Corridor)"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
B-7 CORRIDOR — MAINTENANCE LOG
|
||||
Logged by: Pael Varren
|
||||
|
||||
[Month 11, Day 4]
|
||||
Air scrubber pressure variance. Reset. Source unclear.
|
||||
|
||||
[Month 11, Day 12]
|
||||
Seal failure B-7 junction. Third occurrence. Replaced again.
|
||||
Noted: floor wear pattern inconsistent with posted traffic.
|
||||
Possible deferred maintenance backlog creating additional load.
|
||||
|
||||
[Month 11, Day 22]
|
||||
Recurring seal failure. Escalated to deferred queue.
|
||||
NOTE: Pressure sensors reading elevated load during 14:00-15:00
|
||||
window. Does not match shift transition foot traffic.
|
||||
Filed maintenance anomaly report — awaiting management review.
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
Pael's logged the pressure anomaly. It'll sit in the queue.
|
||||
Management never reviews the anomaly reports.
|
||||
detective: >
|
||||
Maintenance anomaly report filed, awaiting management review.
|
||||
The report documents irregular pressure load during exact operation
|
||||
window. If management reviews it, the window closes.
|
||||
tags: [investigation-relevant, b7-corridor, pael, critical]
|
||||
knowledge_grant:
|
||||
fact_id: "location.corridor_b7_restricted"
|
||||
confidence: knows_of
|
||||
|
||||
# ==========================================
|
||||
# ROUTING DIAGRAM — cargo flow map posted at junction
|
||||
# ==========================================
|
||||
|
||||
- id: env_corridor_004
|
||||
type: sign
|
||||
label: "Cargo Routing Diagram"
|
||||
visibility: always
|
||||
text: >
|
||||
SOVA TRANSIT — MAINTENANCE CORRIDOR ROUTING
|
||||
[Technical diagram showing freight flow paths]
|
||||
|
||||
Standard freight path: Bay > Terminal > Surface Storage
|
||||
Emergency reroute: Bay > B-corridor > Temporary Hold (C-4)
|
||||
Maintenance access: B-corridor > Service Lift > Environmental Systems
|
||||
|
||||
[Someone has drawn a thin pencil line from Bay 4 to C-4 to
|
||||
the service lift, bypassing the standard terminal route]
|
||||
dual_lens:
|
||||
smuggler: "Someone mapped the reroute. That's not how we were shown it. That's cleaner."
|
||||
detective: >
|
||||
Pencil annotation on the official diagram. Whoever added it knows
|
||||
the layout and took time to trace an alternative route. Not vandalism.
|
||||
Operational planning.
|
||||
tags: [investigation-relevant, b7-corridor, smuggling-route]
|
||||
knowledge_grant:
|
||||
fact_id: "location.smuggling_route"
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# ENVIRONMENTAL WARNING — recycled air quality
|
||||
# ==========================================
|
||||
|
||||
- id: env_corridor_005
|
||||
type: sign
|
||||
label: "Environmental Quality Notice"
|
||||
visibility: always
|
||||
text: >
|
||||
CORRIDOR ENVIRONMENTAL STATUS — CURRENT READINGS
|
||||
|
||||
CO2: 0.12% [ACCEPTABLE — upper threshold 0.15%]
|
||||
Particulate: ELEVATED — see maintenance queue
|
||||
Temperature: 19.4°C [nominal]
|
||||
Recycled air freshness: 73% [minimum recommended: 80%]
|
||||
|
||||
If recycled air quality falls below 70%, report to maintenance.
|
||||
Current status: Monitor.
|
||||
dual_lens:
|
||||
smuggler: "Recycled air's worse than the terminal. You get used to it."
|
||||
detective: "Air quality below recommended. Maintenance deferred. Nobody's prioritizing this corridor."
|
||||
tags: [atmospheric, environmental]
|
||||
|
||||
# ==========================================
|
||||
# TEMPORARY STORAGE MANIFEST SHEET
|
||||
# At the C-4 temporary hold point
|
||||
# ==========================================
|
||||
|
||||
- id: env_corridor_006
|
||||
type: document
|
||||
label: "Temporary Storage Log (C-4)"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
C-4 TEMPORARY HOLD — CONTENTS LOG
|
||||
[Printed manifest, partial, two pages visible]
|
||||
|
||||
Container 4466 — Cleared — removed Month 11 Day 1
|
||||
Container 4467 — Cleared — removed Month 11 Day 3
|
||||
Container 4468 — Cleared — removed Month 11 Day 3
|
||||
Container 4469 — [NO ENTRY]
|
||||
Container 4470 — Cleared — removed Month 11 Day 8
|
||||
Container 4471 — [PENDING — entered Month 11 Day 15, no removal logged]
|
||||
Container 4472 — Cleared — removed Month 11 Day 18
|
||||
|
||||
[Gap in numbering between 4469-4470 has no explanation column]
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
4471 is still logged as pending. It should have been moved during
|
||||
last transition. Either it wasn't, or the log wasn't updated.
|
||||
Either is a problem.
|
||||
detective: >
|
||||
Container 4471 in the C-4 hold log, entered Month 11, no removal
|
||||
logged. Same container flagged in the terminal manifest. And 4469
|
||||
is a gap with no explanation. This is the thread.
|
||||
tags: [investigation-relevant, container-4471, critical]
|
||||
knowledge_grant:
|
||||
fact_id: "investigation.cargo_anomaly"
|
||||
confidence: knows_of
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Environmental Text: The Last Shift (Bar)
|
||||
# Signs, terminals, documents, readable objects.
|
||||
# Atmosphere-heavy. Some investigation relevance through social texture.
|
||||
# Ticket: #262 | Sprint: 12
|
||||
|
||||
location: the-last-shift
|
||||
items:
|
||||
|
||||
# ==========================================
|
||||
# DRINK MENU — handwritten, scratched-out prices
|
||||
# Above the bar. Visible to anyone.
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_001
|
||||
type: sign
|
||||
label: "Drink Menu"
|
||||
visibility: always
|
||||
text: >
|
||||
THE LAST SHIFT
|
||||
(Lera's)
|
||||
|
||||
Krenn grain spirit — 4c
|
||||
Pale station brew — 3c ~~2c~~
|
||||
Dark station brew — 3c
|
||||
Import (off-station) — 8c
|
||||
Sova standard water — 1c
|
||||
Meal (hot, whatever's running) — 6c
|
||||
|
||||
No tabs. No Commission business at the bar.
|
||||
House rules posted.
|
||||
dual_lens:
|
||||
smuggler: "Import's doubled since last year. Everything's tighter."
|
||||
detective: >
|
||||
"No Commission business at the bar." That's Lera's personal policy.
|
||||
She'll have opinions about why.
|
||||
tags: [atmospheric]
|
||||
|
||||
# ==========================================
|
||||
# LERA'S RULES — above the bar
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_002
|
||||
type: sign
|
||||
label: "House Rules"
|
||||
visibility: always
|
||||
text: >
|
||||
LERA'S — HOUSE RULES
|
||||
|
||||
1. Pay before you're too drunk to remember.
|
||||
2. Arguments outside. Always.
|
||||
3. What's said at the bar stays at the bar.
|
||||
4. No job talk after 20:00.
|
||||
5. Torek Lintar runs his own tab. Don't ask.
|
||||
dual_lens:
|
||||
smuggler: "Rule 5. Lera's been running Torek's tab for years. Old arrangement."
|
||||
detective: >
|
||||
Rule 5 is specific. Named. Torek Lintar has a standing arrangement
|
||||
here. That's the third time his name has come up without context.
|
||||
tags: [investigation-relevant, torek-lintar, lera]
|
||||
knowledge_grant:
|
||||
fact_id: "relationship.torek_bar_arrangement"
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# NOTICE BOARD — general community notices
|
||||
# Examine to read individual notices.
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_003
|
||||
type: readable_object
|
||||
label: "Notice Board"
|
||||
visibility: always
|
||||
text: >
|
||||
[NOTICE BOARD — various papers pinned over each other]
|
||||
|
||||
MISSING: Vessels Tamm, last seen Sova Transit District, Month 11.
|
||||
Contact: Lattice Commission Sova liaison or speak to Lera.
|
||||
|
||||
CARGO WORKERS' ASSOCIATION — Monthly meeting, back room, Month 12,
|
||||
evening shift. All Sova Transit workers welcome. Bring your own.
|
||||
|
||||
ROOM AVAILABLE: Residential pod B-3, lower level. Quiet. Short-term.
|
||||
Speak to Harek at the bar.
|
||||
|
||||
[handwritten, pinned over the others:]
|
||||
ANYONE KNOW WHO LEFT A TRANSIT BAG IN THE STORAGE ROOM?
|
||||
IT'S BEEN THREE WEEKS.
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
Vessels Tamm. Missing person notice. That name's from the Tamm family —
|
||||
Naia's connection? Or coincidence. Probably coincidence.
|
||||
detective: >
|
||||
Missing persons notice. Vessels Tamm. Sova Transit District. Cross-reference
|
||||
Tamm with case file — possible Naia Tamm relation. Flag for follow-up.
|
||||
tags: [investigation-relevant, naia-tamm-adjacent, harek]
|
||||
knowledge_grant:
|
||||
fact_id: "relationship.tamm_family"
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# GRAFFITI — bathroom wall
|
||||
# Examine to read.
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_004
|
||||
type: readable_object
|
||||
label: "Graffiti (Bathroom Wall)"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
[scratched into the wall surface over several years]
|
||||
|
||||
Voss doesn't check bay 4. Never has. Never will.
|
||||
|
||||
The Commission sees what it wants to see.
|
||||
|
||||
Lera knows.
|
||||
|
||||
[someone added in different handwriting:]
|
||||
Lera doesn't know anything. She just sells drinks.
|
||||
|
||||
[original hand, below:]
|
||||
Keep telling yourself that.
|
||||
dual_lens:
|
||||
smuggler: "Graffiti's been here since before I started. The Voss line is right, though."
|
||||
detective: >
|
||||
"Lera knows." Someone believes the bar owner has information.
|
||||
Someone else pushes back. The debate is years old.
|
||||
tags: [atmospheric, voss, lera, investigation-adjacent]
|
||||
|
||||
# ==========================================
|
||||
# TONIGHT'S SPECIALS — chalkboard near the kitchen
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_005
|
||||
type: sign
|
||||
label: "Tonight's Specials"
|
||||
visibility: always
|
||||
text: >
|
||||
TONIGHT:
|
||||
Hot grain porridge (Velen style) — 5c
|
||||
Station fish fry — 7c
|
||||
|
||||
Ask Lera what Velen style means.
|
||||
She'll tell you about it for twenty minutes.
|
||||
dual_lens:
|
||||
smuggler: "Lera's from Velen originally. She makes everyone ask."
|
||||
detective: "Lera Sessik. Originally from Velen surface. Personal detail noted."
|
||||
tags: [atmospheric, lera-backstory]
|
||||
|
||||
# ==========================================
|
||||
# LATTICE-FREE ZONE SIGN — near the entrance
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_006
|
||||
type: sign
|
||||
label: "Lattice-Free Zone Notice"
|
||||
visibility: always
|
||||
text: >
|
||||
VOLUNTARY LATTICE-FREE ZONE
|
||||
|
||||
By Lera's request: please disable active lattice scanning
|
||||
and recording functions while at the bar.
|
||||
|
||||
Conversation here is conversation. Not data.
|
||||
|
||||
This is not a Commission-recognized designation.
|
||||
Lera doesn't care.
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
Everyone honors this. It's one of the reasons the bar works for
|
||||
what it works for.
|
||||
detective: >
|
||||
Not legally enforceable. But anyone who honors it voluntarily is
|
||||
actively choosing to reduce their surveillance footprint here.
|
||||
Who honors it and who doesn't is worth noting.
|
||||
tags: [investigation-relevant, lera, lattice-free]
|
||||
|
||||
# ==========================================
|
||||
# CARGO WORKERS' ASSOCIATION FLYER — pinned to the bar
|
||||
# ==========================================
|
||||
|
||||
- id: env_bar_007
|
||||
type: document
|
||||
label: "Association Meeting Flyer"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
SOVA TRANSIT CARGO WORKERS' ASSOCIATION
|
||||
Monthly Meeting
|
||||
|
||||
Topic: Shift change protocols — the thirty-minute handoff window
|
||||
and why it isn't long enough.
|
||||
|
||||
Also on agenda: Updated manifest compliance requirements from
|
||||
the Commission (new reporting window: 6 hours, down from 8).
|
||||
|
||||
Back room. Evening shift. All welcome.
|
||||
Bring your concerns, bring a drink.
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
Commission shortened the reporting window to six hours. That's
|
||||
tighter than the eight-hour buffer. Someone noticed the gap.
|
||||
detective: >
|
||||
Agenda item: thirty-minute handoff window. Workers complaining
|
||||
about it publicly means the window is known beyond the ring.
|
||||
Or is being discussed for reasons that are less innocent.
|
||||
tags: [investigation-relevant, shift-transition, commission]
|
||||
knowledge_grant:
|
||||
fact_id: "world.shift_schedule"
|
||||
confidence: knows_of
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
# Environmental Text: The Terminal (Logistics Hub)
|
||||
# Signs, terminals, documents, readable objects.
|
||||
# Both characters can examine these. Interpretation differs.
|
||||
# Ticket: #262 | Sprint: 12
|
||||
|
||||
location: the-terminal
|
||||
items:
|
||||
|
||||
# ==========================================
|
||||
# SHIFT BOARD — posted schedule
|
||||
# Always visible. High-traffic reference point.
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_001
|
||||
type: sign
|
||||
label: "Shift Schedule Board"
|
||||
visibility: always
|
||||
text: >
|
||||
SOVA TRANSIT DISTRICT — LOGISTICS HUB
|
||||
CURRENT ROTATION — CYCLE 47
|
||||
|
||||
BAY 1-4: Day Shift 06:00-14:00 (Voss, K. — Supervisor)
|
||||
BAY 5-8: Day Shift 06:00-14:00 (Maret, K. — Scheduler)
|
||||
TRANSITION: 13:45-14:15 (30-min handoff window)
|
||||
Night Shift 22:00-06:00 — ROSTER PENDING
|
||||
[Bay 6: MAINTENANCE HOLD — see Pael Varren]
|
||||
dual_lens:
|
||||
smuggler: "Thirty-minute transition window. That's the window. Posted for everyone to see."
|
||||
detective: "Night roster blank. Either unassigned or deliberately obscured. Logging the gap."
|
||||
|
||||
# ==========================================
|
||||
# MANIFEST TRACKING TERMINAL — cargo status display
|
||||
# Active terminal. Can be read from appropriate distance in investigation mode.
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_002
|
||||
type: terminal
|
||||
label: "Cargo Tracking Terminal"
|
||||
visibility: always
|
||||
text: >
|
||||
ACTIVE MANIFEST QUEUE — SOVA LOGISTICS
|
||||
Last updated: 06:23
|
||||
|
||||
Container 4471 — [PENDING REVIEW] — Bay 4
|
||||
Container 4472 — CLEARED — Bay 2
|
||||
Container 4473 — CLEARED — Bay 2
|
||||
Container 4474 — [WEIGHT DISCREPANCY — 47kg] — Bay 4
|
||||
Container 4475 — CLEARED — Bay 3
|
||||
Container 4476 — PROCESSING — Bay 7
|
||||
[8 additional items — scroll to continue]
|
||||
dual_lens:
|
||||
smuggler: "4471 is still in the queue. Kael hasn't cleared it yet. On schedule."
|
||||
detective: >
|
||||
4471 pending review with a 4474 weight discrepancy in the same bay.
|
||||
Two anomalies, same location. Not random.
|
||||
tags: [investigation-relevant, manifest-discrepancy]
|
||||
knowledge_grant:
|
||||
fact_id: "investigation.manifest_discrepancy"
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# MAINTENANCE REQUEST CLIPBOARD
|
||||
# Physical clipboard near loading arm 3. Examine to read.
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_003
|
||||
type: document
|
||||
label: "Maintenance Request Log"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
DEFERRED MAINTENANCE — CURRENT QUEUE
|
||||
(Submitted: Pael Varren, Maintenance)
|
||||
|
||||
Loading arm 3 — Grinding during lift cycle — DEFERRED x4
|
||||
Bay 6 environmental seal — Recurring failure — DEFERRED x3 (HOLD)
|
||||
B-7 corridor air scrubber — Pressure variance — DEFERRED x1
|
||||
Gate calibration bay 5-6 — Off-spec hum — DEFERRED x2
|
||||
Recycling unit C-4 — Thermal spike — DEFERRED x1
|
||||
|
||||
Note: B-7 corridor showing irregular pressure load on sensor.
|
||||
Traffic heavier than logged shift count explains. Investigating.
|
||||
— P. Varren
|
||||
dual_lens:
|
||||
smuggler: "Pael's noticed the B-7 pressure log doesn't match posted traffic. Nothing they can do about it."
|
||||
detective: >
|
||||
Maintenance log records irregular pressure load in B-7.
|
||||
The tech attributes it to unlogged foot traffic. That's exactly right.
|
||||
tags: [investigation-relevant, b7-corridor]
|
||||
knowledge_grant:
|
||||
fact_id: "location.corridor_b7_restricted"
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# COMMISSION COMPLIANCE NOTICE
|
||||
# Standard posting. Required by regulation. Two years old.
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_004
|
||||
type: sign
|
||||
label: "Commission Compliance Notice"
|
||||
visibility: always
|
||||
text: >
|
||||
LATTICE COMMISSION — FREIGHT OPERATIONS COMPLIANCE
|
||||
|
||||
All manifest discrepancies exceeding 15kg must be reported
|
||||
within 8 hours of discovery.
|
||||
|
||||
All cargo flagged PENDING REVIEW must be cleared by assigned
|
||||
inspector before shift transition.
|
||||
|
||||
Unauthorized access to restricted maintenance corridors
|
||||
will result in immediate suspension pending review.
|
||||
|
||||
Posted: Lattice Commission, Sova Station Authority
|
||||
Reference: Commission Order 7741-B
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
Eight-hour window on discrepancy reporting. In practice, the window
|
||||
to move anything is the thirty-minute shift transition.
|
||||
detective: >
|
||||
8-hour reporting window. If discrepancy 4474 was logged at 06:23,
|
||||
compliance deadline is 14:23. I have time.
|
||||
tags: [atmospheric, institutional]
|
||||
|
||||
# ==========================================
|
||||
# WORK ORDER NOTE — handwritten, coffee-stained
|
||||
# Personal communication between dock workers. Examine to read.
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_005
|
||||
type: document
|
||||
label: "Handwritten Note (Coffee-Stained)"
|
||||
visibility: examine_only
|
||||
text: >
|
||||
Kael —
|
||||
|
||||
Bay 4 queue backed up again. Maret says it's a weight issue
|
||||
but she's not flagging it officially. If it's still there when
|
||||
you come in, just route it through 7 like last time. Voss
|
||||
won't check.
|
||||
|
||||
— D.
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
"D." is Drin. Route it through 7 like last time — dock-side
|
||||
workaround, happens constantly. Probably nothing operational.
|
||||
detective: >
|
||||
Unsigned note, initial D. Maret not flagging a weight discrepancy
|
||||
officially. Voss complicit or uninformed. Follow up with Maret.
|
||||
tags: [investigation-relevant, maret, drin, voss]
|
||||
knowledge_grant:
|
||||
fact_id: "investigation.drin_inspection_pattern"
|
||||
confidence: suspects
|
||||
|
||||
# ==========================================
|
||||
# SAFETY PROCEDURES PLACARD — standard, faded
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_006
|
||||
type: sign
|
||||
label: "Emergency Procedures"
|
||||
visibility: always
|
||||
text: >
|
||||
IN CASE OF EMERGENCY:
|
||||
|
||||
1. Activate hull breach alarm (red panel, every 20m)
|
||||
2. Proceed to nearest designated shelter (yellow markers)
|
||||
3. Do not use freight lifts during emergency protocols
|
||||
4. Await station authority clearance before resuming operations
|
||||
|
||||
Last safety review: Month 4, Year 312 (Krenn calendar)
|
||||
dual_lens:
|
||||
smuggler: "Two years out of date. Nobody bothers."
|
||||
detective: "Last review was two cycles ago. Standard compliance lag for a district this size."
|
||||
tags: [atmospheric]
|
||||
|
||||
# ==========================================
|
||||
# BAY STATUS BOARD — current bay availability
|
||||
# ==========================================
|
||||
|
||||
- id: env_terminal_007
|
||||
type: sign
|
||||
label: "Bay Status Board"
|
||||
visibility: always
|
||||
text: >
|
||||
BAY STATUS — CURRENT CYCLE
|
||||
|
||||
BAY 1: ACTIVE
|
||||
BAY 2: ACTIVE
|
||||
BAY 3: ACTIVE
|
||||
BAY 4: ACTIVE — PENDING REVIEW (2 items)
|
||||
BAY 5: ACTIVE
|
||||
BAY 6: MAINTENANCE HOLD — Est. clear: next cycle
|
||||
BAY 7: ACTIVE
|
||||
BAY 8: SCHEDULED DOWNTIME 13:00-15:00
|
||||
dual_lens:
|
||||
smuggler: "Bay 8 scheduled downtime 13:00-15:00. That overlaps the transition window. Useful."
|
||||
detective: "Bay 8 downtime during transition window. Reduced oversight at peak movement time."
|
||||
tags: [investigation-relevant, operational]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user